All findings · Lucene finding · LFV-132 · standardtokenizer-maxtokenlength1-supplementary-char-truncates-stream

StandardTokenizer and UAX29URLEmailTokenizer with maxTokenLength 1 stop at the first emoji or other supplementary character, so the rest of the text is never indexed

With setMaxTokenLength(1), StandardTokenizer and UAX29URLEmailTokenizer take the first character above U+FFFF, such as an emoji, for the end of the text: "x ๐Ÿ˜€ y" gives only [x], and y is never indexed. Every release since 4.10.0 is affected; the default (255) and every value of 2 or more are not.

Summary

Priority
Low, rank 776Low 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
silent wrong results
Since
4.10.0 (LUCENE-5897, 2014-08-22)introduced 2014-08-22 · over 12 years
Affected
every release 4.10.0 to 10.6.0 (113), and main
Status
documented
Found by
non-formal: a manual probe of the scanner's refill code, then an exhaustive check of short inputs

AI-assisted page: this finding was first signalled by code reading or testing 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

Token length, the scan buffer and surrogate pairs

  1. StandardTokenizer splits text into words. maxTokenLength caps a token's length in chars, and a longer word is cut into pieces: at 5, toolong gives toolo and ng.
  2. Since 4.10.0 the tokenizer reads text through a scan buffer exactly maxTokenLength chars long that never grows. When the buffer is full, the current piece simply ends there.
  3. 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.
Diagram of the bug: the text 'ab ๐€ cd' read through a 1-char scan buffer with maxTokenLength 1. 'a' and 'b' become tokens; U+10400 takes two chars and does not fit, the tokenizer reports the end of the text, and 'c' and 'd' are never indexed (final offset 3 of 8). With maxTokenLength 2 all words are kept.
With maxTokenLength 1 the scan buffer holds one char. U+10400 needs two, so the tokenizer stops there: 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.

The finding, step by step

  1. You set maxTokenLength to 1 on StandardTokenizer (or StandardAnalyzer, 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".
  2. You tokenize "ab ๐€ cd": 8 chars, because U+10400 (๐€) takes two Java chars, a surrogate pair. You expect a, b, c, d and a final offset of 8.
  3. You get [a, b] and a final offset of 3. Everything after U+10400 is silently gone; an emoji does the same. With maxTokenLength 2 the same text gives [ab, ๐€, cd] and 8.
  4. 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.

Who is affected

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.

Terms

  • 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 skeleton gradle/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.

What happens

  1. setMaxTokenLength(length) calls scanner.setBufferSize(length) (StandardTokenizer.java:95-105, :103), which allocates new char[length] (StandardTokenizerImpl.java:713-718). UAX29URLEmailTokenizer.java:100-108 does the same.
  2. zzRefill() requests zzBuffer.length - zzEndRead - zzFinalHighSurrogate chars (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, returns true, 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.
  3. With a 1-char buffer, every refill reads 1 char. After a, b and the space, the char read is \uD801: it is held back, the buffer is left empty, and zzRefill returns true before any char of the new token was scanned.
  4. The scanner turns that into YYEOF (StandardTokenizerImpl.java:1004-1015) and, because nothing was consumed (zzStartRead == zzCurrentPos), sets zzAtEOF = true and returns YYEOF (:1034-1037); later calls return YYEOF without reading (:997-999). incrementToken() returns false (StandardTokenizer.java:159-161) and end() reports yychar() + yylength() = 3 (:178-185). UAX29URLEmailTokenizerImpl.java:33659-33664 and :33932-33935 are 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.)

The numbers

input, maxTokenLength 1tokensfinal 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 255all words keptinput 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), or maxTokenLength="1" on StandardTokenizerFactory / 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, CheckIndex clean.
  • 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"], e is 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. checkAnalysisConsistency and checkRandomData on StandardAnalyzer with maxTokenLength 1 fail (finalOffset expected:<8> but was:<3>; random data expected:<12> but was:<10>) and pass at 2.

Minimal repro

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.

TestStandardTokenizerMaxTokenLengthOne.java (one test class)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.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());
  }
}
Command
cd $LUCENE && ./gradlew :lucene:core:test --tests TestStandardTokenizerMaxTokenLengthOne -Ptests.seed=DEADBEEF -Ptests.asserts=true
Expected
c and d are 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.

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

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.

Environment

lucenemain 645d4fe14a20 (11.0.0-SNAPSHOT): fails; 10.6.0, 10.5.2 and 9.12.3: fail; with the fix: passes
jdkOpenJDK 25.0.3 (build 25.0.3+9-LTS)
osLinux, aarch64 (the only platform run; plain Java char handling)
Command (environment)
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

Observed output

# 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

Why it looks unintended

  • 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-99 and StandardAnalyzer.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); TestUAX29URLEmailTokenizerFactory asserts that message for -1.
  • Where the tokenizer drops text on purpose, it continues. StandardTokenizer.java:163-173 skips 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. checkAnalysisConsistency passes text.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. ClassicTokenizer and WhitespaceTokenizer with maxTokenLength 1 report the full final offset. WhitespaceTokenizer(1) gives [a, b, ๐€, c, d] on "ab ๐€ cd". ClassicTokenizer skips, 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.

Suggested fix

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-173 gives), 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, regenerateUAX29URLEmailTokenizer with 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: TestStandardAnalyzer 45, TestStandardFactories 3, the repro, 3 validation-only methods) and org.apache.lucene.analysis.email.* (analysis-common, 92 tests: TestUAX29URLEmailTokenizer 46, TestUAX29URLEmailAnalyzer 34, TestUAX29URLEmailTokenizerFactory 10, 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; checkAnalysisConsistency and checkRandomData (200 iterations) pass for StandardAnalyzer(1) and UAX29URLEmailAnalyzer(1); UAX29URLEmailTokenizer(1) keeps c and d;
  • regenerateStandardTokenizerChecksumCheck, regenerateUAX29URLEmailTokenizerChecksumCheck, checkGoogleJavaFormat, forbiddenApisMain and ecjLintMain pass.

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.

Reject maxTokenLength below 2

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 note

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.

How it was found

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 setMaxTokenLength boundary 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 two return true branches.
  • 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).

Prior reports, history and checks

Prior reports

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).

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs and commits (apache/lucene; Elasticsearch, OpenSearch, Solr, Lucene.NET, JFlex, all of GitHub)2026-10-31a 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 PRsbackground 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 web2026-10-3160 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 searchesfour 2015/2016 threads about long ASCII tokens being chopped (intended); nothing about surrogates or lost text
Earlier pass2026-10-26288 queries (tool runs, GraphQL issue/PR searches, lists, JIRA REST, Stack Overflow, forums, web, NVD, GitHub advisories), plus the finder's 51-query passno report; 58 hits read, none this bug

History

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).

Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20; 10.6.0 affectedminimal 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) affectedhistory 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 bisectthe 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 affectedno text lost (4.9.1: [a, b, c, d], 8 of 8)
main 645d4fe14a20 with the fix not affectedrepro passes; [a, b, c, d], final offset 8

Independent checks

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.

Each independent check and its verdict
CheckVerdictWhat it found
Finder (analysis hunting unit) candidate4 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, highOwn test r U+1F680 s gives [r] with three seeds and -da; intended argument weak.
Independent reproducer realOwn 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, highIntended 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 realLucene's stock checkAnalysisConsistency and checkRandomData fail at maxTokenLength 1 and pass at 2.
Adjudication confirmed, highRe-ran the independent reproduction's and the finder's tests with -ea and -da; no dissent.
History follow-up 4.10.0, LUCENE-589725 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 realThe one-test repro fails on 645d4fe14a20, 10.5.2 and 10.6.0 with -ea and -da.
Second adjudication real, highRe-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 validatedGradle 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.

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

Finding record

LFV-132, snapshot of 2026-11-06

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 main 2f424fd428da (2026-10-30) has the same code [read].

Lucene and upstream sources

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-33944
  • lucene/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-185
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/analysis/standard/StandardTokenizerImpl.java:713-718, :753-804, :997-1015, :1034-1037
  • a 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-33935
  • a 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, :60
  • a 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-173
  • a Lucene main checkout (645d4fe14a20) lucene/analysis/common/src/java/org/apache/lucene/analysis/email/UAX29URLEmailTokenizer.java:91-99; StandardAnalyzer.java:65-70
  • a Lucene main checkout (645d4fe14a20) lucene/test-framework/src/java/org/apache/lucene/tests/analysis/BaseTokenStreamTestCase.java:1423-1471
  • a Lucene main checkout (645d4fe14a20) lucene/analysis/common/src/test/org/apache/lucene/analysis/email/TestUAX29URLEmailTokenizerFactory.java:217-219
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/analysis/TokenStream.java:142-160
  • a 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)

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

Repro files

About the diagrams

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).

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.