StandardTokenizersplits text into words.maxTokenLengthcaps a token's length in chars, and a longer word is cut into pieces: at 5,toolonggivestooloandng.- Since 4.10.0 the tokenizer reads text through a scan buffer exactly
maxTokenLengthchars long that never grows. When the buffer is full, the current piece simply ends there. - Java stores a character above U+FFFF (most emoji, rare CJK ideographs, mathematical letters) as two chars, a surrogate pair. A 1-char buffer can never hold one.
a and b are indexed, c and d are not. Select the still to open it at full size. Generated with AI, checked against the code.- You set
maxTokenLengthto 1 onStandardTokenizer(orStandardAnalyzer,UAX29URLEmailTokenizer, or a factory). The setter accepts 1 to 1,048,576 and promises that longer tokens are "chopped up at this token length and emitted as multiple tokens". - You tokenize
"ab ๐ cd": 8 chars, because U+10400 (๐) takes two Java chars, a surrogate pair. You expecta,b,c,dand a final offset of 8. - You get
[a, b]and a final offset of 3. Everything after U+10400 is silently gone; an emoji does the same. WithmaxTokenLength2 the same text gives[ab, ๐, cd]and 8. - Why: the setting makes the scan buffer exactly 1 char, and it never grows. The pair cannot fit, so the refill code holds its first half back and answers "no more input". No token has started, so the scanner takes that as the real end of the text.
Only applications that set maxTokenLength to 1 on StandardTokenizer, StandardAnalyzer, UAX29URLEmailTokenizer, UAX29URLEmailAnalyzer or their factories. For them, one emoji, rare CJK ideograph or mathematical letter silently drops the rest of that field value from search, with assertions on or off. No known code uses 1; the default 255 is unaffected.
Mechanism, terms and numbers669 words
StandardTokenizer.setMaxTokenLength(n) sizes the JFlex scan buffer to exactly n chars, and Lucene's no-expansion skeleton never grows it. With n = 1, the first code point above U+FFFF cannot fit: zzRefill() holds the high surrogate back and returns true ("no new input") with nothing in the buffer, and at a token start the scanner reads that as the real end of input. So StandardTokenizer, StandardAnalyzer, UAX29URLEmailTokenizer, UAX29URLEmailAnalyzer and their factories with maxTokenLength = 1 emit nothing after the first emoji, CJK Extension B ideograph or mathematical letter in a value, and end() reports a short final offset ("ab ๐ cd": [a, b], 3 of 8). No error, with assertions on and off. Only maxTokenLength = 1 is affected on well-formed text; the setting is legal but unusual. It ships in every release from 4.10.0 (LUCENE-5897, 2014) to 10.6.0. A 6-line change to the shared skeleton, regenerated for both scanners, fixes it.
- Supplementary character: a code point above U+FFFF. In Java it is two
chars, a high surrogate followed by a low surrogate. The repro uses U+10400 (๐, DESERET CAPITAL LETTER LONG I) =\uD801\uDC00; emoji such as U+1F600 behave the same. - Scan buffer: the
char[]the JFlex-generated scanner (StandardTokenizerImpl,UAX29URLEmailTokenizerImpl) reads into. Both are generated from Lucene's skeletongradle/regenerate/jflex/skeleton.disable.buffer.expansion.txt, whose buffer never grows. - Final offset: the offset
end()reports after the last token; Lucene's consistency checker expects it to equal the input length.
setMaxTokenLength(length)callsscanner.setBufferSize(length)(StandardTokenizer.java:95-105,:103), which allocatesnew char[length](StandardTokenizerImpl.java:713-718).UAX29URLEmailTokenizer.java:100-108does the same.zzRefill()requestszzBuffer.length - zzEndRead - zzFinalHighSurrogatechars (skeleton:98). If the last char read is a high surrogate and the read filled the request, it holds that char back (--zzEndRead; zzFinalHighSurrogate = 1;) and, if only 1 char was read, returnstrue, meaning "no new input" (skeleton:112-117,StandardTokenizerImpl.java:783-788). This exists so that a full buffer ends the current partial token; the next refill puts the surrogate back.- With a 1-char buffer, every refill reads 1 char. After
a,band the space, the char read is\uD801: it is held back, the buffer is left empty, andzzRefillreturnstruebefore any char of the new token was scanned. - The scanner turns that into
YYEOF(StandardTokenizerImpl.java:1004-1015) and, because nothing was consumed (zzStartRead == zzCurrentPos), setszzAtEOF = trueand returnsYYEOF(:1034-1037); later calls returnYYEOFwithout reading (:997-999).incrementToken()returnsfalse(StandardTokenizer.java:159-161) andend()reportsyychar() + yylength()= 3 (:178-185).UAX29URLEmailTokenizerImpl.java:33659-33664and:33932-33935are the same code.
An instrumented trace on 645d4fe14a20 confirms the order: two normal chops after a and b, then the numRead == 1 return at a token start, then zzAtEOF. (Earlier records also named a second refill with requested == 0; the trace shows it never runs.)
| input, maxTokenLength 1 | tokens | final offset |
|---|---|---|
ab U+10400 cd (StandardTokenizer, UAX29URLEmailTokenizer) | [a, b] | 3 of 8 |
x U+1F600 y (StandardTokenizer) | [x] | 2 of 6 |
U+1D400 ok (StandardTokenizer) | [] | 0 of 5 |
pU+20000q (no spaces) | [p] | 1 of 4 |
Go U+1F680 now (StandardAnalyzer) | [g, o] | 3 of 9 |
factory maxTokenLength=1, ok U+1D400 yes | [o, k] | 3 of 9 |
| control: the same text at maxTokenLength 2 or 255 | all words kept | input length |
control: ab cd at maxTokenLength 1 | [a, b, c, d] | 5 of 5 |
On an index, with StandardAnalyzer(maxTokenLength 1) on go U+1F600 now, a TermQuery for n finds 0 documents; without the emoji it finds 1. A bounded-exhaustive check of the stock jar (3,294,144 runs: all 137,256 well-formed inputs of 1 to 6 symbols over a 7-symbol alphabet: a, space, 1, ., U+4E2D, and the supplementary U+10400 and U+1F600; maxTokenLength 1-5 and 255, two Reader shapes, both tokenizers) found violations only at maxTokenLength 1, on exactly the inputs with a supplementary code point.
Exposure and likelihood189 words
- Trigger. An opt-in, unusual setting:
setMaxTokenLength(1), ormaxTokenLength="1"onStandardTokenizerFactory/UAX29URLEmailTokenizerFactory(StandardAnalyzer.java:87,:92;UAX29URLEmailAnalyzer.java:92,:97;StandardTokenizerFactory.java:46,:60;UAX29URLEmailTokenizerFactory.java:61). No in-tree code sets 1; a partial public code search found only tests. - Certain once configured. Any code point above U+FFFF in a field value triggers it: emoji, CJK Extension B ideographs, mathematical letters.
- Effect. Silent wrong results: searches for the later words miss the document, while the stored text still shows them. No exception,
CheckIndexclean. - Contained to one value. Later documents, other fields and later values of the same field are tokenized normally, because
reset()clears the scanner state. Inside that document, the lost tokens shift the positions, and the short final offset shifts the offsets, of later values of the same multi-valued field (for["ab ๐ cd", "ef"],eis at position 2 and offset[4,5)instead of 4 and[9,10)), which can misplace offset-based highlighting there. - Lucene's own checker fails.
checkAnalysisConsistencyandcheckRandomDataonStandardAnalyzerwith maxTokenLength 1 fail (finalOffset expected:<8> but was:<3>; random dataexpected:<12> but was:<10>) and pass at 2.
One JUnit class, one test method, core only, public API: StandardTokenizer with setMaxTokenLength(1) on "ab ๐ cd". It asserts that c and d are emitted and that the final offset is 8, and nothing about U+10400 itself. Deterministic, single thread, no index.
/*
* 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.standard;
import java.io.IOException;
import java.io.StringReader;
import java.util.ArrayList;
import java.util.List;
import org.apache.lucene.analysis.tokenattributes.CharTermAttribute;
import org.apache.lucene.analysis.tokenattributes.OffsetAttribute;
import org.apache.lucene.tests.util.LuceneTestCase;
public class TestStandardTokenizerMaxTokenLengthOne extends LuceneTestCase {
public void testTextAfterSupplementaryCharIsKept() throws IOException {
String text = "ab \uD801\uDC00 cd";
StandardTokenizer t = new StandardTokenizer();
t.setMaxTokenLength(1);
t.setReader(new StringReader(text));
CharTermAttribute term = t.addAttribute(CharTermAttribute.class);
OffsetAttribute offset = t.addAttribute(OffsetAttribute.class);
List<String> tokens = new ArrayList<>();
t.reset();
while (t.incrementToken()) {
tokens.add(term.toString());
}
t.end();
t.close();
// expected: c and d are still emitted and the final offset is 8; actual: [a, b] and 3
assertTrue(
"expected c and d after U+10400, got " + tokens, tokens.containsAll(List.of("c", "d")));
assertEquals("final offset", text.length(), offset.endOffset());
}
}
cd $LUCENE && ./gradlew :lucene:core:test --tests TestStandardTokenizerMaxTokenLengthOne -Ptests.seed=DEADBEEF -Ptests.asserts=true
- Expected
canddare emitted after U+10400, and the final offset is 8.- Observed
expected c and d after U+10400, got [a, b](final offset 3), with assertions on and off.
More files, environment, full output228 words
LuceneTestCase is only the base class; no randomness is used. The source is ASCII-only, with a \uD801\uDC00 escape. The lost tokens are asserted first, the final offset second.
Regression test: this same class (or its one method added to TestStandardAnalyzer); it passes with the recommended fix. That fix changes the shared skeleton, so the same method also applies to UAX29URLEmailTokenizer (TestUAX29URLEmailTokenizer). The repro checks the later terms and the final offset only, because the offsets and position increments depend on the behaviour chosen for the code point; the validation-only test also asserts them for the recommended fix.
| lucene | main 645d4fe14a20 (11.0.0-SNAPSHOT): fails; 10.6.0, 10.5.2 and 9.12.3: fail; with the fix: passes |
|---|---|
| jdk | OpenJDK 25.0.3 (build 25.0.3+9-LTS) |
| os | Linux, aarch64 (the only platform run; plain Java char handling) |
cp $REPRO/TestStandardTokenizerMaxTokenLengthOne.java $LUCENE/lucene/core/src/test/org/apache/lucene/analysis/standard/
cd $LUCENE && ./gradlew :lucene:core:test --tests TestStandardTokenizerMaxTokenLengthOne -Ptests.seed=DEADBEEF -Ptests.asserts=true
# assertions off fails the same way: -Ptests.asserts=false
# gradle, stock 645d4fe14a20, -Ptests.seed=DEADBEEF -Ptests.asserts=true (stack trimmed; -Ptests.asserts=false identical)
> java.lang.AssertionError: expected c and d after U+10400, got [a, b]
> at __randomizedtesting.SeedInfo.seed([DEADBEEF:F3E43334B8C5D9F6]:0)
> at org.junit.Assert.fail(Assert.java:89)
> at org.junit.Assert.assertTrue(Assert.java:42)
> at org.apache.lucene.analysis.standard.TestStandardTokenizerMaxTokenLengthOne.testTextAfterSupplementaryCharIsKept(TestStandardTokenizerMaxTokenLengthOne.java:44)
:lucene:core:test (FAILURE): 1 test, 1 failure
# the same test without the token assertion: final offset expected:<8> but was:<3>
# plain main(), no test framework, -ea and -da:
maxTokenLength=1 tokens=[a, b] finalOffset=3 inputLength=8
maxTokenLength=2 tokens=[ab, U+10400, cd] finalOffset=8 inputLength=8- The setter accepts 1 and promises that longer tokens are "chopped up" into several; nothing says the stream may end early.
- The tokenizer's own fallback skips a too-long token and continues; Lucene's consistency checker fails at 1 and passes at 2.
- The early return exists to end a token in a full buffer; no discussion or test mentions 1-char buffers.
The full argument287 words
- The contract is "chop", not "stop".
StandardTokenizer.java:86-94,UAX29URLEmailTokenizer.java:91-99andStandardAnalyzer.java:65-70: "Tokens larger than this will be chopped up at this token length and emitted as multiple tokens." No javadoc, CHANGES or MIGRATE entry says values below 2 are unsupported or that the stream may end early. - 1 is explicitly accepted and documented. The setter's range is
[1, 1048576]; the lower bound "maxTokenLength must be greater than zero" came with LUCENE-5559 (225a9966b64, 2014-04-01);TestUAX29URLEmailTokenizerFactoryasserts that message for -1. - Where the tokenizer drops text on purpose, it continues.
StandardTokenizer.java:163-173skips a too-long token that the scanner produced, with a position gap, and goes on; nothing in the tokenizer ends the stream early on purpose. (This bug never reaches that path: the scanner itself reports the end of the input.) - Lucene's checker.
checkAnalysisConsistencypassestext.length()as the expected final offset (BaseTokenStreamTestCase.java:1423-1471); it fails at maxTokenLength 1 and passes at 2. - Intent of the change. LUCENE-5897 (#6959) disabled buffer growth so that partial matches are limited to the buffer size, and added the early return after the hold-back so that a full buffer ends the current token (the same branch was posted on jflex-de/jflex#538). No discussion of it mentions a 1-char buffer or supplementary characters, and its tests use maxTokenLength 200 to 8192.
- Siblings read to the end of the input.
ClassicTokenizerandWhitespaceTokenizerwith maxTokenLength 1 report the full final offset.WhitespaceTokenizer(1)gives[a, b, ๐, c, d]on"ab ๐ cd".ClassicTokenizerskips, rather than chops, words longer than maxTokenLength, so on"ab ๐ cd"it emits no tokens (final offset 8 of 8), and on the single-letter"p q ๐ r s"it gives[p, q, r, s].
The devil's-advocate check133 words
The strongest case for "intended" (rated moderate by the works-as-designed reviewer, OpenAI GPT-5.6): the buffer is fixed on purpose for performance (LUCENE-5897), and a 2-char code point cannot be chopped to length 1 without splitting a surrogate pair, so something has to give; and TokenStream.end() (TokenStream.java:142-160) only says it sets "the final offset", not its value.
Why it fails: that argument leaves open only what happens to the one code point that cannot fit (drop it, skip it with a gap, emit it whole). It does not justify losing every later, unrelated word of the value, which no document or test accepts. The repro asserts nothing about the code point itself, and it leads with the lost tokens; the final offset rests on Lucene's checker convention. Verdict: not intended, a defect.
Recommended In the shared skeleton, do not report end of input for a lone high surrogate
Change the shared JFlex skeleton so zzRefill does not report end of input when a high surrogate is the only char in the buffer: it returns false, and the scanner reads the two halves one at a time. Regenerated with JFlex, each scanner gains the same 6 lines; the repro and the standard and email analysis suites pass.
if (numRead == requested) { // We requested too few chars to encode a full Unicode character
if (zzEndRead == 1) {
// A one-char buffer (maxTokenLength 1) can never hold the pair. Holding the high
// surrogate back would leave nothing to scan, which reads as the end of input,
// so scan it on its own; the low surrogate follows with the next refill.
return false;
}
--zzEndRead;
zzFinalHighSurrogate = 1;
if (numRead == 1) {
return true;
}All options and trade-offs3 options ยท 757 words
One fix was written and validated in a scratch worktree of 645d4fe14a20; the study's grow-the-buffer sketch was run earlier as a shadow of one scanner only, and rejecting the value was not run. Which behaviour Lucene wants for the one code point that cannot fit is the maintainers' choice.
In the shared skeleton, do not report end of input for a lone high surrogate (validated by the checks) (recommended)
In zzRefill() of skeleton.disable.buffer.expansion.txt, when the high surrogate just read is the only char in the buffer (zzEndRead == 1, which on well-formed input happens only with a 1-char buffer), return false instead of holding it back: the scanner then reads the two halves one at a time, neither forms a token, and scanning continues. StandardTokenizerImpl.java and UAX29URLEmailTokenizerImpl.java were regenerated with JFlex 1.9.1 from the changed skeleton; each gains exactly the same 6 lines, and the two checksum files change.
if (numRead == requested) { // We requested too few chars to encode a full Unicode character
if (zzEndRead == 1) {
// A one-char buffer (maxTokenLength 1) can never hold the pair. Holding the high
// surrogate back would leave nothing to scan, which reads as the end of input,
// so scan it on its own; the low surrogate follows with the next refill.
return false;
}
--zzEndRead;
zzFinalHighSurrogate = 1;
if (numRead == 1) {
return true;
}Trade-offs
- Smallest change: one place, both tokenizers, and the buffer still never grows.
- The supplementary code point itself produces no token and no position gap, like punctuation. If maintainers want a gap (as the skip path in
StandardTokenizer.java:163-173gives), the grow-to-2 option does that. - With a buffer of 2 or more the new branch is unreachable on well-formed input, so the default and every other value behave as before.
- It also changes malformed input: a lone high surrogate alone in a 1-char buffer is now scanned instead of ending the stream. The fact check ran 20,000 random strings with lone surrogates per tokenizer at maxTokenLength 1: on stock about 15,200 end with a short final offset, with the fix none, and no exceptions either way. No test covers that case.
What the checks validated
Gradle --no-daemon in a scratch worktree of 645d4fe14a20, OpenJDK 25.0.3 (2026-10-31). Stock: the repro fails with assertions on and off. With the fix:
- regenerated with JFlex 1.9.1 (
regenerateStandardTokenizer,regenerateUAX29URLEmailTokenizerwith the TLD download skipped): each scanner gains exactly the 6 lines and nothing else; the two checksum files change (+22/-4 in 5 files); - the repro passes with assertions on and off;
org.apache.lucene.analysis.standard.*(core, 52 tests:TestStandardAnalyzer45,TestStandardFactories3, the repro, 3 validation-only methods) andorg.apache.lucene.analysis.email.*(analysis-common, 92 tests:TestUAX29URLEmailTokenizer46,TestUAX29URLEmailAnalyzer34,TestUAX29URLEmailTokenizerFactory10, 2 validation-only methods; 1 skipped): 0 failures with seeds DEADBEEF and CAFEBABE;- the validation-only methods, which all fail on stock:
StandardAnalyzer(1)on"ab ๐ cd"gives exactly[a, b, c, d], offsets[0,1) [1,2) [6,7) [7,8), final offset 8;checkAnalysisConsistencyandcheckRandomData(200 iterations) pass forStandardAnalyzer(1)andUAX29URLEmailAnalyzer(1);UAX29URLEmailTokenizer(1)keepscandd; regenerateStandardTokenizerChecksumCheck,regenerateUAX29URLEmailTokenizerChecksumCheck,checkGoogleJavaFormat,forbiddenApisMainandecjLintMainpass.
The whole core and analysis-common test suites were not run.
Let the 1-char buffer grow to 2 chars for one pending surrogate pair (the study's sketch) (validated by the checks)
When a high surrogate is pending at a token start in a 1-char buffer, grow the buffer to 2, read the low surrogate, and shrink back once the buffer is empty. By the sketch's design the code point is then scanned as a 2-char token, and StandardTokenizer skips it as too long, with a position gap.
Trade-offs
Breaks the "buffer never grows" design by one char, and needs its shrink step so that later tokens are not scanned 2 chars long (and then skipped). The sketch patches only the generated StandardTokenizerImpl, so UAX29URLEmailTokenizer still fails with it; a real patch has to move it into the skeleton.
What the checks validated
Run earlier as a shadow of the generated StandardTokenizerImpl only: the repro passes on 645d4fe14a20, and the study's run passed the 45 TestStandardAnalyzer tests; the UAX29URLEmailTokenizer case still fails with it.
Change the checks in StandardTokenizer.java:96-97 and UAX29URLEmailTokenizer.java:101-102 to length < 2, and the javadoc range to [2, 1048576].
Trade-offs
Simplest and obviously safe, but a breaking change: applications that set 1 get an IllegalArgumentException, so it fits a major release, and the repro would have to assert the exception instead. Not run.
Downstream engines whose max_token_length index setting reaches these tokenizers (read, not tested) inherit the loss: with 1, text after an emoji is invisible to search, including to moderation or alerting that searches the field, while the stored text still shows it.
A hunting AI tool reviewing boundary cases of setMaxTokenLength read the scanner's refill code, tried maxTokenLength 1 with a supplementary character and saw the stream end. A written case analysis of zzRefill gave the cause, and a bounded-exhaustive check of the real jar (3,294,144 runs) showed that only maxTokenLength 1 is affected.
The method in full127 words
Method: non-formal (a manual probe first; the exhaustive check came after, to characterise it).
- Finder. A hunting unit on analysis code (StandardTokenizer
setMaxTokenLengthboundary tests as its secondary method) read the JFlex refill code (zzRefill), probed maxTokenLength 1 with a supplementary code point, and wrote a case analysis of the tworeturn truebranches. - Characterised by bounded-exhaustive translation validation of the stock jar: all 137,256 well-formed inputs of 1 to 6 symbols over a 7-symbol alphabet (
a, space,1,., U+4E2D, U+10400, U+1F600), maxTokenLength 1-5 and 255, whole-string and 1-char-per-read Readers, both tokenizers, 3,294,144 runs; violations only at maxTokenLength 1. - Checked by independent reproducers, two executed cross-model reviews, an adjudication, the version-history runs, a current-main re-check and a tie-break (see below).
No prior report: 258 fresh queries on 2026-10-31 (GitHub incl. downstream and JFlex, the mailing lists, JIRA, the web) and the file lists of all 409 open PRs, after a 288-query pass on 2026-10-26. Closest: #14645 (open: replace the no-expansion skeleton) and jflex-de/jflex#1090 (the same buffer state elsewhere).
Searches3 searches
Verdict new, meaning the searches below found no public report (keyword search cannot prove absence). Not fixed upstream: the skeleton is the same on main, branch_11x, branch_10_6 and branch_9_12; the only open PR touching it, #16949, changes a javadoc line. Related hunt bug, kept separate: LFV-158 (a value ending in an unpaired high surrogate loses tokens; same zzRefill, different branch and trigger).
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues, PRs and commits (apache/lucene; Elasticsearch, OpenSearch, Solr, Lucene.NET, JFlex, all of GitHub) | 2026-10-31 | a scripted prior-report search with three spec variants (classes, symptoms, JFlex skeleton: 48 GitHub, 9 commit, 6 downstream queries), 66 gh issue/PR searches, 7 open-PR searches, and the changed files of all 409 open apache/lucene PRs | background only: #14645, #6959, #8811, #16949 / #16927; precedent jflex-de/jflex#1090 with FasterXML/jackson-dataformats-text PR #403 / issue #411 (fixed by PR #412) |
| Mailing lists, JIRA archive and the web | 2026-10-31 | 60 tool list queries plus 20 direct lists.apache.org queries (dev@, java-user@, issues@, commits@), 12 + 7 JIRA queries (LUCENE, SOLR, LUCENENET), 13 web and StackExchange searches | four 2015/2016 threads about long ASCII tokens being chopped (intended); nothing about surrogates or lost text |
| Earlier pass | 2026-10-26 | 288 queries (tool runs, GraphQL issue/PR searches, lists, JIRA REST, Stack Overflow, forums, web, NVD, GitHub advisories), plus the finder's 51-query pass | no report; 58 hits read, none this bug |
Introduced in 4.10.0 by LUCENE-5897 / LUCENE-5400 (2014-08-22; acf9242850 on trunk, 33204ddd895 on the 4.10 branch), which fixed the scan buffer at maxTokenLength chars and added the early return. 25 affected releases from 4.10.0 to 10.6.0 executed; 4.9.0 and 4.9.1 are clean. Not fixed.
Versions tested5 rows
Introduced by LUCENE-5897 / LUCENE-5400 (#6959, #6463; svn era, no PR; 4.10.0's own CHANGES, which today's lucene/CHANGES.txt lists under 4.9.1: "The scanner default buffer size was reduced, and scanner buffer growth was disabled"), committed 2014-08-22 as trunk acf9242850 (Steven Rowe), branch_4x 86dcbcebf2b and lucene_solr_4_10 33204ddd895. It made setMaxTokenLength size the buffer and inserted if (totalRead == 1) { return true; } after the hold-back. It needed the hold-back of LUCENE-5770 (JFlex 1.6; trunk b2071884216, merged to branch_4x as 91280250725 on 2014-07-06, first released in 4.10.0; the trunk commit itself first ships in 6.0.0), which is harmless with a growing buffer. LUCENE-8527 (283b19a8da6, 2019) only moved the same logic into the skeleton file, and LUCENE-10239 (2021) regenerated it. 4.9.1 has its own backport on JFlex 1.5.1 code without the hold-back. Before 4.10.0 a too-long token was skipped, not chopped, so the history probe uses single-letter words and the final offset. The JUnit repro compiles only against 9.1.0 and later, because LuceneTestCase moved to org.apache.lucene.tests.util in 9.1.0 (LUCENE-10301).
| Version or commit | Verdict | Result |
|---|---|---|
main 645d4fe14a20; 10.6.0 | affected | minimal repro fails with assertions on and off; history probe [a, b], final offset 3 of 8 |
| 4.10.0 to 10.5.2 (24 releases run: 4.10.0, 4.10.4, 5.0.0, 5.5.5, 6.0.0, 6.6.6, 7.0.0, 7.6.0, 7.7.0, 7.7.3, 8.0.0, 8.8.2, 8.9.0, 8.10.0, 8.10.1, 8.11.0-8.11.4, 9.0.0, 9.12.3, 10.0.0, 10.5.2) | affected | history probe [a, b], 3 of 8; the JUnit repro also fails on 9.12.3 and 10.5.2; the other releases by tag scan |
4.10.0 jars + the five tokenizer sources of 33204ddd895 vs of its parent 3d04f6194be | bisect | the commit's sources fail ([a, b], 3 of 8); the parent's pass |
| 4.9.0 and 4.9.1 (releases from 3.1 to 4.8.x: tag scan only) | not affected | no text lost (4.9.1: [a, b, c, d], 8 of 8) |
main 645d4fe14a20 with the fix | not affected | repro passes; [a, b, c, d], final offset 8 |
Every executed check found it real: the finder, the independent reproduction and the intended-behaviour check, two executed OpenAI GPT-5.6 reviews, the adjudication, a current-main re-check and a tie-break with an instrumented trace. This doc's gradle runs reproduced it and validated the fix.
Checks and verdicts323 words
Every executed check ran on stock builds or SHA-1-checked release jars. No executed check dissented.
| Check | Verdict | What it found |
|---|---|---|
| Finder (analysis hunting unit) | candidate | 4 of 5 public-API tests fail on main with -ea and -da, also on 10.5.2 and 9.12.3; the exhaustive check: violations only at maxTokenLength 1. |
| Refute lens (OpenAI GPT-5.6, executed) | real, high | Own test r U+1F680 s gives [r] with three seeds and -da; intended argument weak. |
| Independent reproducer | real | Own 9 tests: 6 fail on main and 10.5.2 with -ea and -da and on 9.12.3 with -ea, the 3 controls pass; its scope probe finds violations only at maxTokenLength 1. |
| Works-as-designed lens (OpenAI GPT-5.6, executed) | real, high | Intended argument moderate but rejected; ClassicTokenizer(1) and WhitespaceTokenizer(1) keep r and s of "p q ๐ r s"; the study's sketch leaves UAX29URLEmailTokenizer broken. |
| Intended-behaviour devil's advocate | real | Lucene's stock checkAnalysisConsistency and checkRandomData fail at maxTokenLength 1 and pass at 2. |
| Adjudication | confirmed, high | Re-ran the independent reproduction's and the finder's tests with -ea and -da; no dissent. |
| History follow-up | 4.10.0, LUCENE-5897 | 25 affected releases run, 4.9.0 and 4.9.1 clean, commit isolation on the 4.10.0 jars; corrects the earlier "introduced by LUCENE-8527". |
| Current-main re-check | real | The one-test repro fails on 645d4fe14a20, 10.5.2 and 10.6.0 with -ea and -da. |
| Second adjudication | real, high | Re-runs on main, 10.6.0, 10.5.2 and 9.12.3; own 9-case probe (4 bug cases fail, 5 controls pass); a plain main(); an index-level TermQuery; an instrumented trace that corrects the mechanism wording. |
| This doc | real; fix validated | Gradle in a scratch worktree: stock fails with assertions on and off. The fix, regenerated with JFlex for both scanners, passes the repro, 52 core and 92 analysis-common tests with two seeds (including 5 validation-only methods that all fail on stock), and the regenerate-checksum, format and lint checks. |
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 476 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,428 words
- Registry title
- StandardTokenizer and UAX29URLEmailTokenizer with setMaxTokenLength(1) end the token stream at the first supplementary character (for example an emoji), so all later text of the field value is silently left out of the index
- Area
- analysis / StandardTokenizer and UAX29URLEmailTokenizer (JFlex no-buffer-expansion scanner)
- Priority
- Low, rank 776: Low at the end of the niche-configuration cluster: with maxTokenLength=1 (a degenerate setting nobody is known to use) any emoji or other supplementary character silently drops the rest of the field value from the index since 4.10; certain once configured.
- Impact class
- silent wrong results
- Impact note
- Only applications that set maxTokenLength = 1 (legal and documented, but opt-in and unusual) on StandardTokenizer, StandardAnalyzer, UAX29URLEmailTokenizer or their factories are hit; for them every field value that contains a code point above U+FFFF (an emoji, a CJK Extension B ideograph, a mathematical letter) loses everything after that character from the index and gets a short final offset, with no error, deterministically and at ordinary scale, with -ea and -da; the default 255 and every value >= 2 are unaffected for well-formed text; released 4.10.0 through 10.6.0 (10.6.0 by reading).
- Summary
- You set maxTokenLength to 1 on StandardTokenizer (or StandardAnalyzer, UAX29URLEmailTokenizer, or a factory with maxTokenLength=1) and tokenize text that contains a code point above U+FFFF, for example 'ab U+10400 cd' or 'hello U+1F600 world'. You expect what the setter documents, that too-long tokens are chopped and emitted as multiple tokens, so the words after that character are still tokenized and end() reports the input length as the final offset. Instead the 1-char scan buffer cannot hold the surrogate pair, the scanner's refill reports end of input at a token start, and the stream stops: [a, b] with final offset 3 of 8, [h, e, l, l, o], and nothing after the character is indexed.
- Introduced
- acf9242850739874c1f30ad7daf262081dac900f (LUCENE-5897 / LUCENE-5400, svn r1619730, trunk, the line of 6.0 and today's main), merged the same day to branch_4x as 86dcbcebf2b434df21f42d55a4672eaa90f88b12 (5.x) and to lucene_solr_4_10 as 33204ddd895a26a56c1edd92594800ef285f0d4a (the line released as 4.10.0); no pull request (svn era) [exec: on the 4.10.0 jars, shadowing only the five tokenizer sources of 33204ddd895 fails while the same five sources from its parent 3d04f6194be pass; read: git blame -w -C -C -M and git log -S on the skeleton, git tag --contains]; it needed the zzFinalHighSurrogate hold-back of LUCENE-5770 (JFlex 1.6, b2071884216c, same 4.10.0 release), which alone is harmless; LUCENE-8527 (283b19a8da6, 2019-01-08) only moved the same logic from build.xml edits into the skeleton file (7.6.0 and 7.7.0 fail identically), correcting the finder's, the independent reproduction's and the adjudication's attribution to LUCENE-8527 (2014-08-22 (LUCENE-5897 committed to trunk, branch_4x and lucene_solr_4_10 the same day))
- First affected version
- 4.10.0 [exec]
- Affected versions
- 4.10.0 through 10.5.2 (112 releases), 10.6.0 (by reading) and main ea57360912ed: the follow-up's history probe fails on 4.10.0, 4.10.4, 5.0.0, 5.5.5, 6.0.0, 6.6.6, 7.0.0, 7.6.0, 7.7.0, 7.7.3, 8.0.0, 8.11.4, 9.0.0, 9.12.3, 10.0.0, 10.5.2 and main with -ea, and on 4.10.0, 9.12.3, 10.5.2 and main also with -da; the canonical JUnit repro fails on 9.1.0, 9.12.3, 10.0.0, 10.5.2 and main; the independent reproduction's repro on 10.5.2 (-ea, -da), 9.12.3 and main; the finder's main() on 8.11.4, 7.7.3, 6.6.6 and 5.5.5 [exec]; the other releases from a code scan of all 103 release tags 4.10.0..10.5.2 in the history clone (both the eof branch and the setBufferSize call present, no earlier tag has the branch) and git tag --contains [read]; 8.8.2 and 8.9.0-8.11.3 have no tag in the history clone and are inferred from the scan through 8.8.1 and the executed 8.11.4 failure [read]; unaffected: every release before 4.10.0, and 4.9.1 (a different LUCENE-5897 backport onto JFlex 1.5.1 code without the hold-back), executed on 4.0.0, 4.9.0 and 4.9.1 (-ea and -da); main, branch_10x, branch_10_6 and branch_9_12 still carry both early returns [read, upstream contents API, 2026-10-26], so 10.6.0 (released 2026-10-29) is affected
- Status
- documented (brief, checks and a per-finding doc exist)
- Verification
- Confirmed by AI-run checks: an independent repro, a cross-model review that agreed, an adjudication that ruled it real, an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
- Found by
- non-formal: code reading; also reached by exhaustive check
- Upstream and prior reports
- none found (2026-10-26: the follow-up's search, 288 logged queries: scripted prior-report searches A and B (mailing lists dev@, java-user@, issues@ and commits@ 40, JIRA archive 8, web 8, GitHub 16, commits 3, downstream 2), 76 GitHub GraphQL issue/PR searches on apache/lucene, elastic/elasticsearch, opensearch-project/OpenSearch, apache/solr, apache/lucenenet, jflex-de/jflex and all of GitHub, 65 direct lists.apache.org queries (dev@, java-user@, issues@, commits@, general@), 15 JIRA REST queries (LUCENE, SOLR, LUCENENET), 10 Stack Overflow, 12 Discourse forum and 13 web searches, 3 GitHub code searches, NVD and the GitHub advisory database; 58 distinct hits read or grepped, none of them this bug; the changed-file lists of all 413 open apache/lucene PRs, none touching the skeletons, analysis/standard/ or analysis/email/; plus the finder's independent 51-query pass and the intended-behaviour check's extra gh searches on apache/lucene and jflex-de/jflex); upstream main HEAD equals the tested commit, and main, branch_10x, branch_10_6 and branch_9_12 still carry both early returns; related items, none of them this bug: #6959 (LUCENE-5897, origin of the early return), #6463 (LUCENE-5400), #8811 (LUCENE-7760, the chopping contract), #9573 (LUCENE-8527), #452 / #11275 (LUCENE-10239), #14645 (open, idle since 2025-09-08: replace the no-expansion skeleton; a contributor's draft re-creates the same branch), jflex-de/jflex#538 and #203 (the branch posted or copied, its consequence not discussed), jflex-de/jflex#1090 with FasterXML/jackson-dataformats-text#411 / #412 (the same buffer state in another project's skeleton gives an IndexOutOfBounds there), #15899 / #15900 (surrogate handling at a length limit in TruncateTokenFilter, a precedent); no CVE or advisory
- Lucene commit tested
- main
645d4fe14a20(2026-10-28): the minimal test fails with assertions on and off through gradle in a scratch worktree and through a standalone runner; with the recommended fix (skeleton changed, both scanners regenerated with JFlex 1.9.1) it passes, as do the standard and email analysis suites with two seeds, Lucene's consistency and random-data checkers at maxTokenLength 1 for both tokenizers, and the regenerate-checksum, format and lint checks. Releases: 25 affected releases from 4.10.0 to 10.6.0 executed (SHA-1-checked jars), 4.9.0 and 4.9.1 clean, plus a commit isolation on the 4.10.0 jars. Upstream main2f424fd428da(2026-10-30) has the same code [read].
- Finding record
gradle/regenerate/jflex/skeleton.disable.buffer.expansion.txt:80-129 (zzRefill: 'int requested = zzBuffer.length - zzEndRead - zzFinalHighSurrogate;' and 'if (requested == 0) { return true; }' at :98-101; held-back high surrogate 'zzFinalHighSurrogate = 1; if (numRead == 1) { return true; }' at :111-117)lucene/core/src/java/org/apache/lucene/analysis/standard/StandardTokenizerImpl.java:713-718 (setBufferSize), :753-806 (generated zzRefill, requested == 0 at :770, numRead == 1 at :786), :1004-1015 (eof from zzRefill sets zzInput = YYEOF), :1034-1037 ('if (zzInput == YYEOF && zzStartRead == zzCurrentPos) { zzAtEOF = true; return YYEOF; }')lucene/analysis/common/src/java/org/apache/lucene/analysis/email/UAX29URLEmailTokenizerImpl.java:33627-33689 (same generated zzRefill, requested == 0 at :33646, numRead == 1 at :33662), :33900, :33933-33944lucene/core/src/java/org/apache/lucene/analysis/standard/StandardTokenizer.java:86-105 (setMaxTokenLength javadoc 'Tokens larger than this will be chopped up at this token length and emitted as multiple tokens', IllegalArgumentException only outside [1, MAX_TOKEN_LENGTH_LIMIT], scanner.setBufferSize(length)), :178-185 (end(): finalOffset = correctOffset(scanner.yychar() + scanner.yylength())), :193-198 (reset() -> yyreset)lucene/analysis/common/src/java/org/apache/lucene/analysis/email/UAX29URLEmailTokenizer.java:91-110 (same javadoc and range); lucene/core/src/java/org/apache/lucene/analysis/standard/StandardAnalyzer.java:66-71, :87, :92 and UAX29URLEmailAnalyzer.java:78, :92, :97 (setMaxTokenLength passed to the tokenizer); StandardTokenizerFactory.java:60 and UAX29URLEmailTokenizerFactory.java:61 (tokenizer.setMaxTokenLength(maxTokenLength))
- The finding in 30 seconds
a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/analysis/standard/StandardTokenizer.java:86-105, :159-173, :178-185a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/analysis/standard/StandardTokenizerImpl.java:713-718, :753-804, :997-1015, :1034-1037a Lucene main checkout (645d4fe14a20) gradle/regenerate/jflex/skeleton.disable.buffer.expansion.txt:80-133 (:98-101, :112-117)a Lucene main checkout (645d4fe14a20) lucene/analysis/common/src/java/org/apache/lucene/analysis/email/UAX29URLEmailTokenizer.java:91-110; UAX29URLEmailTokenizerImpl.java:33627-33670, :33932-33935a Lucene main checkout (645d4fe14a20) lucene/core/src/test/org/apache/lucene/analysis/standard/TestStandardAnalyzer.java (testMaxTokenLengthNonDefault: 'toolong' at 5 gives toolo, ng)
- Who is affected
a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/analysis/standard/StandardAnalyzer.java:87, :92; StandardTokenizerFactory.java:46, :60a Lucene main checkout (645d4fe14a20) lucene/analysis/common/src/java/org/apache/lucene/analysis/email/UAX29URLEmailAnalyzer.java:92, :97; UAX29URLEmailTokenizerFactory.java:61
- Why it looks unintended
a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/analysis/standard/StandardTokenizer.java:86-105, :163-173a Lucene main checkout (645d4fe14a20) lucene/analysis/common/src/java/org/apache/lucene/analysis/email/UAX29URLEmailTokenizer.java:91-99; StandardAnalyzer.java:65-70a Lucene main checkout (645d4fe14a20) lucene/test-framework/src/java/org/apache/lucene/tests/analysis/BaseTokenStreamTestCase.java:1423-1471a Lucene main checkout (645d4fe14a20) lucene/analysis/common/src/test/org/apache/lucene/analysis/email/TestUAX29URLEmailTokenizerFactory.java:217-219a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/analysis/TokenStream.java:142-160a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/analysis/standard/StandardTokenizer.java:92 (range [1, ...] in the javadoc), :96-97
- History
a Lucene main checkout (645d4fe14a20) lucene/CHANGES.txt:12027, :12058 (LUCENE-5897 under 4.9.1), :4339, :4350 (LUCENE-10301 under 9.1.0)
- TestStandardTokenizerMaxTokenLengthOne.java (one test class):
repro/LFV-132/src/org/apache/lucene/analysis/standard/TestStandardTokenizerMaxTokenLengthOne.java(48 lines)
Made from the code at 645d4fe14a20 and executed probe runs. The fix strip at the bottom of the diagram shows the recommended fix, run with both scanners shadowed (assertions on and off).
assets/LFV-132/bug.png(476 KB)
