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.
🔥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.- Index
🔥hot dealwithSimplePatternTokenizer(factory namesimplePattern) and the pattern🔥[0-9]+|[^🔥 ]+: 🔥 followed by digits (like🔥5), or a run of characters other than 🔥 and space. In Java, 🔥 (U+1F525) is twochars, a surrogate pair, at offsets 0 and 1. - You expect
hot[2,5) anddeal[6,10): no match starts at 🔥 (no digit follows), so the next try starts ath. The same tokenizer gives exactly that with[^🔥 ]+alone, and so doesPatternTokenizerwith the same regex. - You get
\uDD25hot[1,5) anddeal. After the failed try at 🔥 the tokenizer steps back to onecharpast the start, which is the emoji's second half, and[^🔥 ]accepts that half. The index stores\uFFFDhot, so aTermQueryforhotfinds 0 documents instead of 1. 🔥 xgives an extra token\uDD25[1,2) beforex.SimplePatternSplitTokenizerwith the separators[^a-z🔥]+|🔥!cutsso🔥hot nowintoso\uD83D,hot,nowinstead ofso🔥hot,now. The tokenizers do not throw; they emit these tokens with assertions on or off.
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.
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:
- 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), soch= U+1F525 and two units are in the term buffer.step(0, U+1F525)is live (it begins🔥[0-9]+) but not accepting. - Look ahead (
:126-139).his not a digit, so the automaton dies. Nothing was accepted and the input did not end: the false-alarm branch (:153-156). - The false alarm rewinds by
chars.pushBack(tokenUpto - 1)(:155,tokenUpto= 3 units) pushes back U+DD25 andh; 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. - 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). LuceneRegExpnegated 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). - 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 ath. So whether 🔥 is stepped over correctly depends on whether some alternative can start with it.
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.
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
simplePatternandsimplePatternSplit, for example inCustomAnalyzeror 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).
IndexWriterreplaces lone surrogates with U+FFFD as documented,CheckIndexstays 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.
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.
/*
* 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);
}
}
# 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) forE[0-9]+|[^E ]+onE x, and[Eb]at [0,3) for the separator patternE[0-9]+|[^Ea-z]+onEb; actual[U+DD25, x]and[U+D83D, b](lone surrogates), with-eaand-da.
More files, environment, full output60 words
| lucene | Lucene main e1faeba08bde (2026-11-02) |
|---|---|
| jdk | 25.0.3+9-LTS |
# 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)- 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:
[^🔥 ]+giveshot,🔥[0-9]+|[^🔥 ]+gives\uDD25hot. PatternTokenizerandString.splitkeep 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:114andSimplePatternSplitTokenizer.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 giveshot,dealand the second\uDD25hot,deal. The same holds for[^a-z🔥]+vs[^a-z🔥]+|🔥!onso🔥hot now. No start-point rule allows that. - The documented comparisons keep the pair.
PatternTokenizer(group 0) giveshot,dealandx; in split mode (group -1) and withString.split,so🔥hot nowgivesso🔥hot,now. - Supplementary input is in scope. Upstream
testOneTokenandtestNoTokensfeedTestUtil.randomUnicodeStringthrough both tokenizers (with.*, which never fails a try); no dedicated test uses a failing alternative that starts with a supplementary character.TestRandomChainscan build these tokenizers from random regexps that may contain surrogate pairs, but it checks consistency and offsets, not surrogate integrity. - Precedent.
lucene/CHANGES.txtlists 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.
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.
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;TestRandomChainspasses 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.
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.
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.
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.
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.
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).
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).
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.
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues, PRs and commits (apache/lucene), downstream repositories (Elasticsearch, OpenSearch, Solr), mailing lists (dev@, java-user@, issues@, commits@), JIRA archive, web | 2026-10-26 | the hunt's prior-report tool in two runs, 102 queries | no report |
| GitHub (apache/lucene, Elasticsearch, OpenSearch, Solr) | 2026-10-26 | 21 manual searches: class and factory names with surrogate, emoji, supplementary; simple_pattern, simple_pattern_split; LUCENE-7465 | no report |
| JIRA (LUCENE, SOLR) and mailing lists (including the Solr lists) | 2026-10-26 | 13 JIRA and 9 list queries | no report |
| Open PRs and upstream branches | 2026-10-26 | the changed files of all 409 open apache/lucene PRs; both files read on main, branch_10x and branch_10_6 | two PRs touch analysis/pattern, neither this code (#12613, #16911); the line is unchanged on all three branches |
| Related items (read) | 2026-10-26 | issue 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 |
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.
| Version or commit | Verdict | Result |
|---|---|---|
main a9779ef3c193 | affected | this 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 | affected | the 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 | affected | this 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) | affected | the 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 check | the fix applied to the 6.5.0 sources: the test and the probe pass |
| 6.4.2 | not affected | no SimplePattern* classes (the repro does not compile) |
| main + fix | not affected | the repro passes; the analysis-common module, 1,914 tests, passes with assertions on and off |
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.
| Check | Verdict | What it found |
|---|---|---|
| finder | candidate | JUnit 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), procedural | An 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 | real | Through 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 | real | Own 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 | keep | Re-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 validated | A 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 corrected | Re-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. |
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.
- PNG 455 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,443 words
- 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-eaand-da; the 10.5.2 (-ea,-da) and 9.12.3 (-da) release jars. Earlier pipeline runs (2026-10-26): mainea57360912ed, 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).
- 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-160a Lucene main checkout (a9779ef3c193) lucene/analysis/common/src/java/org/apache/lucene/analysis/pattern/SimplePatternSplitTokenizer.java:30-38 (javadoc), 111, 163-167a 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)
- TestSimplePatternSurrogateRetry.java (JUnit test):
repro/LFV-545/src/org/apache/lucene/analysis/pattern/TestSimplePatternSurrogateRetry.java(48 lines)
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.
assets/LFV-545/bug.png(455 KB)
