All findings · Lucene finding · LFV-1202 · standardtokenizer-setmaxtokenlength-midstream-shrink-aioobe

Lowering StandardTokenizer.setMaxTokenLength between two incrementToken() calls makes the next call throw ArrayIndexOutOfBoundsException

If you lower a StandardTokenizer's max token length between two incrementToken() calls, the next call throws ArrayIndexOutOfBoundsException instead of continuing: the setter shrinks the internal text buffer but keeps the read position. Analyzers are not affected; every release since 4.10.0 has it, and UAX29URLEmailTokenizer too.

Summary

Priority
Low, rank 778Lowering a StandardTokenizer's or UAX29URLEmailTokenizer's max token length between two incrementToken() calls of one stream throws an out-of-bounds exception, in every release since 4.10.0. Analyzers and factories set the length before reset(), the failure is loud and recoverable with no wrong tokens, and no real code was found doing it, so it is contrived.
Impact
minor
Since
4.10.0 and 4.9.1 (LUCENE-5897, 2014-08-22)introduced 2014-08-22 · over 12 years
Affected
4.9.1 and 4.10.0 to 10.6.0 (114 releases run, all fail; 4.0.0 to 4.9.0 pass) and main; not fixed upstream
Status
documented
Found by
semi-formal: a proof-obligation audit of StandardTokenizer's contract, witnessed by an executable repro

AI-assisted page: this finding was first signalled by a semi-formal check (an exact check of the real jar, or a modelling hypothesis that a tool then witnessed) and then checked with AI tools; the checks it went through, and what each found, are under Prior reports, history and checks. Not yet triaged by a human. Snapshot of 2026-11-06.

The finding in 30 seconds

setMaxTokenLength also resizes the tokenizer's text buffer: it copies the first min(old, new) chars and keeps the read positions. Lowered mid-stream, below the text already read, those positions point past the end of the new buffer, and the next read fails.

Background: how StandardTokenizer reads text

  1. A consumer calls setReader(...), then reset(), then incrementToken() until it returns false, then end() and close().
  2. StandardTokenizer reads the text into an internal buffer and remembers where it is in that buffer. The max token length (default 255) is also the buffer size: a longer token is chopped into pieces of that length.
  3. setMaxTokenLength(n) accepts any n from 1 to 1,048,576 and resizes the buffer to n chars at once.
Diagram of the bug: a StandardTokenizer reads the text hello into its 255-char buffer and returns the token hello; its read position is 5. setMaxTokenLength(2) replaces the buffer with a 2-char array holding he and keeps the read position at 5. The next incrementToken() compacts the buffer starting at offset 5 of the 2-char array and throws ArrayIndexOutOfBoundsException: arraycopy: last source index 5 out of bounds for char[2], where the expected result is false because no text is left.
The tokenizer has read hello (5 chars) and its read position is 5. setMaxTokenLength(2) swaps the 255-char buffer for a 2-char one but keeps position 5, so the next incrementToken() starts reading past the end and throws. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. Tokenize hello with a StandardTokenizer (default max token length 255): setReader, reset(), and the first incrementToken() returns the only token, hello. All 5 characters are now in the tokenizer's buffer.
  2. Call setMaxTokenLength(2). The value is inside the documented range, 1 to 1,048,576, and the call returns normally.
  3. You expect the next incrementToken() to return false, because no text is left. That is what happens without the call, or when you raise the length instead.
  4. Instead it throws ArrayIndexOutOfBoundsException: arraycopy: last source index 5 out of bounds for char[2]. The setter replaced the 255-char buffer with a 2-char one but kept the read position at 5.
  5. With text still unread (aaaaaaaaaa bbbbbbbbbb, lowered to 4 after the first word) it throws IndexOutOfBoundsException and bbbbbbbbbb is never returned. Setting the length before reset() or between streams works.

Who is affected

Only code that itself lowers a StandardTokenizer's or UAX29URLEmailTokenizer's max token length while reading a stream, also on a tokenizer a factory created. StandardAnalyzer, UAX29URLEmailAnalyzer and the factories set it before reset(), so text sent through them cannot trigger it. The failure is loud: one exception, the rest of that stream is lost, no wrong tokens.

Mechanism, terms and numbers566 words

StandardTokenizer.setMaxTokenLength also resizes the scanner's internal text buffer (StandardTokenizer.java:101-104 at 5adff78bb168), and StandardTokenizerImpl.setBufferSize allocates a new char[length], copies min(old, new) chars and keeps every read position (StandardTokenizerImpl.java:713-718). Lowered between two incrementToken() calls, below the text the scanner has already read, those positions point past the end of the smaller buffer, and the next incrementToken() throws ArrayIndexOutOfBoundsException (from zzRefill, :759) or IndexOutOfBoundsException (from Character.codePointAt, :995), with assertions on and off. The setter's javadoc allows any length in [1, 1048576] and says nothing about when it may be called. Raising the length mid-stream works, and so does lowering it before reset() or between streams; StandardAnalyzer, UAX29URLEmailAnalyzer and the factories set it before reset(), so text sent through them cannot trigger it. UAX29URLEmailTokenizer has the identical code and fails the same way. The resize came with LUCENE-5897 (4.10.0, and the 4.9.1 bugfix release); 114 releases from there to 10.6.0 fail, 4.0.0 to 4.9.0 pass. Resizing only when the buffered text still fits, and otherwise leaving the smaller size to the next reset(), fixes it. Priority Low.

The mechanism (main 5adff78bb168, lucene/core/src/java/org/apache/lucene/analysis/standard/)

  1. The setter resizes the buffer. After the range check: if (length != maxTokenLength) { maxTokenLength = length; scanner.setBufferSize(length); } (StandardTokenizer.java:101-104). The scanner's buffer starts at 255 chars (StandardTokenizerImpl.java:51, :621).
  2. The resize keeps the positions (the fix site). setBufferSize allocates new char[numChars], copies Math.min(zzBuffer.length, numChars) chars and leaves zzStartRead, zzMarkedPos, zzCurrentPos and zzEndRead unchanged (StandardTokenizerImpl.java:713-718; source StandardTokenizerImpl.jflex:151-156).
  3. The repro. Reading hello left zzEndRead = 5 and zzMarkedPos = 5; the new buffer is char[2]. The next getNextToken sets zzStartRead = 5 (:980), finds no buffered char left and calls zzRefill (:1004), which first moves the unread part to the front: System.arraycopy(zzBuffer, zzStartRead, zzBuffer, 0, zzEndRead - zzStartRead) (:756-759). A source offset of 5 in a 2-char array throws.
  4. With unread text left, getNextToken reads Character.codePointAt(zzBuffer, zzCurrentPos, zzEndRead) with zzEndRead past the array (:995): IndexOutOfBoundsException with a null message, and the rest of the stream is lost.
  5. Why other call orders work. Before reset(): reset() calls yyreset (StandardTokenizer.java:193-198), which resets every position (StandardTokenizerImpl.java:832-840). Right after reset(): nothing is buffered yet. After incrementToken() has returned false, or after end(): that call already compacted the buffer (zzRefill, StandardTokenizerImpl.java:756-765), so every position is 0 (zzEndRead = 0) and the end-of-file flag is set; the scanner returns end-of-stream without reading the buffer (executed). Right after the last token, before that call, is still the crash window: the repro does exactly that. Between streams: close() and reset() reset the positions. A new length at least as large as the buffered text (hello world, 11 chars, lowered to 20) and any raise also work.

Window: only between an incrementToken() that returned true and the next call, and only when the new length is below the scanner's high-water mark, the chars it has buffered (zzEndRead + zzFinalHighSurrogate).

Same code elsewhere: UAX29URLEmailTokenizer (analysis-common) has the identical setter and setBufferSize (UAX29URLEmailTokenizer.java:100-110, UAX29URLEmailTokenizerImpl.java:33585-33590) and fails the same way; the suggested fix covers both. ClassicTokenizer's setter only checks the value and sets a field; it resizes no scanner buffer (ClassicTokenizer.java:83-89) and is not affected.

Exposure and likelihood174 words
  • Who. Code that holds one of the two tokenizers directly and lowers setMaxTokenLength between two incrementToken() calls of one stream, below the text the scanner has already read. No in-tree caller or test does this, and the verifiers found no real use.
  • Default vs opt-in. Not on any default path. StandardAnalyzer sets the length right after construction and again in its setReader hook, before reset() (StandardAnalyzer.java:87, :92-93); UAX29URLEmailAnalyzer does the same (UAX29URLEmailAnalyzer.java:92, :97-98); both factories set it in create(). Calling StandardAnalyzer.setMaxTokenLength mid-stream only changes a field that the next tokenStream() applies (the devil's-advocate verifier's probe).
  • Effect. One unchecked exception from the next incrementToken(); the rest of that stream is not tokenized. No wrong tokens were seen in any run. close() and a new reader reset the positions.
  • Scale. Any text longer than the new length that the scanner has already read; 5 characters are enough. Same with assertions on and off.
  • Likelihood. Contrived: the recorded searches found no public report in the twelve years since 4.10.0.

Minimal repro

One JUnit test method on the public API: tokenize hello, read its one token, call setMaxTokenLength(2), and assert that the next incrementToken() returns false. Deterministic and single-threaded; it fails with assertions on and off.

TestStandardTokenizerMaxTokenLengthMidStream.java (lucene/core, package org.apache.lucene.analysis.standard, 1 test)35 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.StringReader;
import org.apache.lucene.tests.util.LuceneTestCase;

public class TestStandardTokenizerMaxTokenLengthMidStream extends LuceneTestCase {

  public void testLowerMaxTokenLengthMidStream() throws Exception {
    StandardTokenizer tokenizer = new StandardTokenizer();
    tokenizer.setReader(new StringReader("hello"));
    tokenizer.reset();
    assertTrue(tokenizer.incrementToken());
    tokenizer.setMaxTokenLength(2);
    // expected: false (no input left); actual: ArrayIndexOutOfBoundsException in StandardTokenizerImpl.zzRefill
    assertFalse(tokenizer.incrementToken());
    tokenizer.end();
    tokenizer.close();
  }
}
Command
cp $REPRO/TestStandardTokenizerMaxTokenLengthMidStream.java $LUCENE/lucene/core/src/test/org/apache/lucene/analysis/standard/
cd $LUCENE && ./gradlew :lucene:core:test --tests org.apache.lucene.analysis.standard.TestStandardTokenizerMaxTokenLengthMidStream -Ptests.seed=DEADBEEF
# and with -Ptests.asserts=false: the same failure
Expected
The second incrementToken() returns false: no text is left.
Observed
ArrayIndexOutOfBoundsException: arraycopy: last source index 5 out of bounds for char[2] at StandardTokenizerImpl.zzRefill(StandardTokenizerImpl.java:759).

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

More files, environment, full output136 words

TestStandardTokenizerMaxTokenLengthMidStream (one method, extends LuceneTestCase, package org.apache.lucene.analysis.standard of the lucene/core tests). The test draws no random values; every seed tried failed. checkGoogleJavaFormat accepts the file.

Environment

lucenemain 5adff78bb168 (12.0.0-SNAPSHOT): fails; 10.6.0 release jars: fails; 4.9.1 and 4.10.0 to 10.6.0 fail and 4.0.0 to 4.9.0 pass with the same steps
jdkOpenJDK 25.0.3 (build 25.0.3+9-LTS)
osLinux aarch64 (nothing platform-specific)
Command (environment)
cp $REPRO/TestStandardTokenizerMaxTokenLengthMidStream.java $LUCENE/lucene/core/src/test/org/apache/lucene/analysis/standard/
cd $LUCENE && ./gradlew :lucene:core:test --tests org.apache.lucene.analysis.standard.TestStandardTokenizerMaxTokenLengthMidStream -Ptests.seed=DEADBEEF
# and with -Ptests.asserts=false: the same failure

Observed output

TestStandardTokenizerMaxTokenLengthMidStream > testLowerMaxTokenLengthMidStream FAILED
    java.lang.ArrayIndexOutOfBoundsException: arraycopy: last source index 5 out of bounds for char[2]
        at __randomizedtesting.SeedInfo.seed([DEADBEEF:FB5A7A5A97DBDDA8]:0)
        at java.base/java.lang.System.arraycopy(Native Method)
        at org.apache.lucene.analysis.standard.StandardTokenizerImpl.zzRefill(StandardTokenizerImpl.java:759)
        at org.apache.lucene.analysis.standard.StandardTokenizerImpl.getNextToken(StandardTokenizerImpl.java:1004)
        at org.apache.lucene.analysis.standard.StandardTokenizer.incrementToken(StandardTokenizer.java:157)
        at org.apache.lucene.analysis.standard.TestStandardTokenizerMaxTokenLengthMidStream.testLowerMaxTokenLengthMidStream(TestStandardTokenizerMaxTokenLengthMidStream.java:31)
1 test completed, 1 failed

# with -Ptests.asserts=false: the same exception and frames
# 10.6.0 release jars, assertions off: the same message at zzRefill(StandardTokenizerImpl.java:751)

Why it looks unintended

  • The javadoc allows any length from 1 to 1,048,576 and never says when the setter may be called; the call returns normally and leaves the tokenizer broken.
  • Raising the length mid-stream works, ClassicTokenizer's setter works, and 4.9.0 passes the test: the crash comes from the 4.10.0 buffer resize.
  • LUCENE-5235 already turned tokenizer-misuse ArrayIndexOutOfBoundsExceptions into IllegalStateException.
The full argument149 words
  • Contract. The setter's javadoc names one failure, IllegalArgumentException outside [1, MAX_TOKEN_LENGTH_LIMIT] (StandardTokenizer.java:86-94). The TokenStream workflow (TokenStream.java:49-62) does not forbid reconfiguring between incrementToken() calls.
  • Broken state, not a check. The setter accepts the call; the exception comes later, from internal positions that point past the scanner's own buffer.
  • Siblings and history. Raising works. ClassicTokenizer's setter (a range check and a field, no buffer resize) works mid-stream. Before acf92428507 (4.10.0) the setter was a range check plus a field assignment and 4.9.0 passes the repro. The resize came with a speed fix (LUCENE-5897, #6959), whose discussion never mentions calls during a stream.
  • Precedent. LUCENE-5235 (34adebab3b8, 2013) replaced tokenizers' "NullPointerException or ArrayIndexOutOfBoundsException on best effort which was not user-friendly" with an IllegalStateException.
  • The repro's answer does not depend on how a new limit should apply: no text is left, so incrementToken() must return false.
The devil's-advocate check138 words

The strongest case for "intended": tokenizers are configured between streams by convention. Every in-tree caller sets the length before reset(); the upstream tests that call the tokenizer's setter do so after close() or right after construction (TestStandardAnalyzer.java:100-108, TestUAX29URLEmailTokenizer.java:65-71, :110-112); ClassicAnalyzer's javadoc says "This setting only takes effect the next time tokenStream or tokenStream is called" (ClassicAnalyzer.java:78). A maintainer may fairly answer "set it before reset()".

Why it fails: the convention is not written on the tokenizer, whose public setter accepts an in-range value and leaves the object broken instead of rejecting the call. It does make "document it and reject cleanly" (fix B) as acceptable as making the call work (fix A). The devil's-advocate verifier judged the claim real at Low; the cross-model reviewer rated the intended-behaviour argument weak.

Suggested fix

Recommended Resize only when the buffered text fits, else at the next reset() (fix A)

Resize the buffer at once only when the text already read still fits; otherwise the next reset() applies the smaller size. A guard around three lines of setBufferSize in each scanner's .jflex. The repro then passes; the 138 existing standard and email analysis tests still pass (1 skipped). Alternative: reject the shrink with IllegalStateException and document "set it before reset()".

   public final void setBufferSize(int numChars) {
     ZZ_BUFFERSIZE = numChars;
     // Resize now only if the input already buffered for the current stream still fits;
     // otherwise yyreset() applies the new size when the next stream starts.
     if (numChars >= zzEndRead + zzFinalHighSurrogate) {
       char[] newZzBuffer = new char[ZZ_BUFFERSIZE];
       System.arraycopy(zzBuffer, 0, newZzBuffer, 0, Math.min(zzBuffer.length, ZZ_BUFFERSIZE));
       zzBuffer = newZzBuffer;
     }
   }
All options and trade-offs3 options ยท 704 words

Fix A was built through gradle on 5adff78bb168, regenerated from the .jflex sources, and checked; fix B's check was run only in the lifecycle probe.

Resize only when the buffered text fits, else at the next reset() (fix A) (validated by the checks) (recommended)

In setBufferSize of StandardTokenizerImpl.jflex and UAX29URLEmailTokenizerImpl.jflex (then regenerate the .java files and checksums), keep the copy only when numChars >= zzEndRead + zzFinalHighSurrogate. yyreset already replaces a buffer larger than ZZ_BUFFERSIZE (StandardTokenizerImpl.java:837-839), so a deferred shrink takes effect at the next reset() or close().

   public final void setBufferSize(int numChars) {
     ZZ_BUFFERSIZE = numChars;
     // Resize now only if the input already buffered for the current stream still fits;
     // otherwise yyreset() applies the new size when the next stream starts.
     if (numChars >= zzEndRead + zzFinalHighSurrogate) {
       char[] newZzBuffer = new char[ZZ_BUFFERSIZE];
       System.arraycopy(zzBuffer, 0, newZzBuffer, 0, Math.min(zzBuffer.length, ZZ_BUFFERSIZE));
       zzBuffer = newZzBuffer;
     }
   }

Trade-offs

Whenever the buffered text fits, fix A runs exactly the statements main runs today (the same copy), so those call orders are unchanged by construction; it differs only when the text does not fit, the crash window. In the crash window the rest of the current stream keeps the old, larger buffer, so a later token longer than the new limit is skipped by the tokenizer's length check (StandardTokenizer.java:163-173) instead of chopped; from the next reset() on it is chopped as documented. Executed: the lifecycle probe on stock and fix A differs only in the two rows that crash on stock (abcdefghij lowered to 3 after the first token now ends normally; aaaaaaaaaa bbbbbbbbbb lowered to 4 gives [aaaaaaaaaa]). Chopping the rest of the stream as well would need the unread text moved to the front first, as zzRefill does; that variant was not written.

What the checks validated

  • Gradle in a scratch worktree at 5adff78bb168, seed DEADBEEF: without the fix the repro fails (assertions on and off), and so do the same steps on UAX29URLEmailTokenizer (a check file, not part of the repro).
  • The .jflex edit was regenerated with the build's own JFlex (regenerateStandardTokenizer, regenerateUAX29URLEmailTokenizer); each regenerated .java differs from main only in this method.
  • With the fix the repro passes with assertions on and off; with assertions on, every core org.apache.lucene.analysis.standard test and every analysis-common org.apache.lucene.analysis.email test pass (including TestStandardAnalyzer, TestUAX29URLEmailTokenizer and the sibling check): 140 tests, 0 failures, 1 skipped. The generated-sources checksum checks and :lucene:core:checkGoogleJavaFormat pass. The analysis-common format check (for the regenerated UAX29URLEmailTokenizerImpl.java) and the full module suites were not run, and only OpenJDK 25.0.3 on Linux aarch64 was used.

Document "set it before reset()" and reject a mid-stream shrink (fix B) (validated by the checks)

Have setBufferSize throw IllegalStateException when numChars < zzEndRead + zzFinalHighSurrogate, assign maxTokenLength only after the scanner accepted, and add a javadoc sentence to both setters.

Trade-offs

Follows the convention and the LUCENE-5235 precedent and is easy to argue. The check fires only in the crash window: once incrementToken() has returned false every scanner position is 0, so lowering the length after the stream or after end() still works. Executed by the fact check with this check shadowed in (lifecycle probe, assertions off): only the two rows that crash on stock throw IllegalStateException; afterExhausted, afterEnd and every other row are unchanged. Set maxTokenLength only after the scanner accepted, or getMaxTokenLength() reports the rejected value. The regression test would assert the exception instead.

What the checks validated

Lifecycle probe only, with the check shadowed in (fact check, assertions off); not run through gradle.

Always defer a shrink to reset() (not recommended) (validated by the checks)

The hunt's first sketch: setBufferSize grows at once and leaves every shrink to yyreset. It passed the repro and TestStandardAnalyzer (45 tests) there.

Trade-offs

It also changes a call order that works today: lowering after reset() but before the first token chops at the new length now ([abc, def, ghi, j], probe row afterReset) and would then skip the long token instead (by reading the code; not run). Fix A keeps that case unchanged.

What the checks validated

The hunt's shadowed runs only (repro and TestStandardAnalyzer, assertions on).

Downstream note

Downstream products are affected only where their own code lowers a tokenizer's max token length in the middle of a stream; text sent through the analyzers or the tokenizer factories cannot trigger it.

How it was found

A proof-obligation audit of StandardTokenizer's contract: an in-range setMaxTokenLength call that the setter accepts must leave the tokenizer usable. The buffer resize breaks that when it shrinks below the scanner's read positions; a public-API JUnit test then witnessed the exception.

The method in full132 words

Method: semi-formal (a proof-obligation audit of a class's contract, witnessed by an executable repro).

  • The finder treated the setter's javadoc (only a range check) and the TokenStream workflow as obligations: an accepted call must keep the scanner's positions inside its buffer. setBufferSize keeps every position while it can cut the buffer below them.
  • The finder wrote a public-API JUnit test (abcdefghij, lowered to 3), ran it on main and the 10.6.0 and 9.1.0 jars, and a probe showed the IndexOutOfBoundsException variant and the working controls (raising mid-stream, shrinking between streams).
  • An independent reproducer, an executed cross-model review and a devil's-advocate verifier reproduced it with their own tests, and an adjudication re-ran them ("Prior reports, history and checks"); the reproducer's smaller test (hello, lowered to 2) became the repro.

Prior reports, history and checks

Prior reports

No prior report found in apache/lucene issues, PRs and commits, the JIRA archive, the mailing lists, downstream trackers or the web (searched 2026-11-03). Nearest: #6959 (LUCENE-5897, the introducing change) and #7740 (LUCENE-6682, the same setter's copy cost).

Searches3 searches

No matching public report was found in the documented searches. Nearest items, none of them this bug: #6959 (LUCENE-5897, the introducing change; its discussion never mentions a call during a stream) and #6463 (LUCENE-5400); #7740 (LUCENE-6682, the setter's copy cost and 1M limit) and its 2015 java-user thread "StandardTokenizer#setMaxTokenLength"; #8811 (LUCENE-7760, javadoc wording); #8050 (a 2016 comment that callers set the buffer size right after construction); open #14645 (jflex 1.9 buffer-size investigation; does not mention this case); open PR #16949 (one yyreset javadoc line in the same file). Limits: keyword search; GitHub search does not index PR diffs or every review comment; private reports are not visible.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs and commits (apache/lucene incl. the migrated JIRA archive)2026-11-03; re-checked the same daya scripted prior-report search plus 43 manual searches (setMaxTokenLength, setBufferSize, StandardTokenizer and UAX29URLEmailTokenizer exceptions, zzRefill), JQL over LUCENE, SOLR and LUCENENET, PRs created or updated since 2026-11-01no report; #6959, #7740 and #8811 are the same setter on other topics
Open PRs2026-11-03the changed files of all 409 open apache/lucene PRsonly #16949 touches StandardTokenizerImpl.java, in one javadoc line
Mailing lists, downstream, web2026-11-03dev@, java-user@, issues@ and commits@; Elasticsearch, OpenSearch, Solr, Lucene.NET and JFlex trackers; Stack Overflow; web queriesnothing about this bug

History

Introduced by acf92428507 (LUCENE-5897, 2014-08-22), first released in 4.10.0 and in 4.9.1. All 114 releases from 4.9.1 and 4.10.0 to 10.6.0 fail; 4.0.0 to 4.9.0 pass. Not fixed upstream.

Versions tested6 rows

Introduced by acf92428507 (Steven Rowe, 2014-08-22), "LUCENE-5897, LUCENE-5400: JFlex-based tokenizers StandardTokenizer and UAX29URLEmailTokenizer tokenize extremely slowly ..." (#6959, #6463; JIRA era, no PR). It cut the scanner buffer to 255 chars, disabled buffer growth, and made the setter, until then a range check plus a field assignment, call the new setBufferSize (the generated zzCanGrow() at StandardTokenizerImpl.java:735 still returns true, but nothing calls it). The same change reached 4.10.0 (33204ddd895) and the 4.9.1 bugfix release (690e43b5cae). In 4.9.1 and 4.10.x the setter resized only the current scanner (match version 4.7 or later); the older compatibility scanners were not resized, and the sweep ran only the default path.

Later changes kept it: LUCENE-6682 (5.3.0 via 9e4df88421b; trunk e92618bbdaf) gave the setter its current if (length != maxTokenLength) form and the 1M limit; LUCENE-7318 (6.2.0, ba922148307) moved the class to core; LUCENE-7760 (9ed722f5655) changed only StandardTokenizer's javadoc (and gave UAX29URLEmailTokenizer its current setter). Upstream main 1c2072f02843 (2026-11-02) has no later change to the files. Not new in 10.6.

Versions and branches executed, with results
Version or commitVerdictResult
4.0.0 to 4.9.0 17 releases, same stepspass: the second incrementToken() returns false
4.9.1, 4.10.0 to 4.10.4 same stepsfail, the same AIOOBE
5.0.0 to 9.12.3 same stepsfail, the same AIOOBE
10.0.0 to 10.6.0 same steps; the repro itself on 10.6.0fail, the same AIOOBE (10.6.0 also with assertions off)
main 5adff78bb168 JUnit, -ea and -dafail, AIOOBE at zzRefill:759
main 5adff78bb168 + fix A gradlepasses; 140 tests incl. the repro: 0 failures, 1 skipped

Independent checks

A finder, an independent reproducer, an executed cross-model review (OpenAI GPT-5.6: real, high), a devil's-advocate verifier (real, Low), an adjudication, an audit and a final repro check reproduced it. This doc's gradle runs reproduced it on main and validated fix A.

Checks and verdicts245 words

The cross-model review ran on main ea57360912ed, where StandardTokenizer.java, StandardTokenizerImpl.java and UAX29URLEmailTokenizer.java are byte-identical to 5adff78bb168's, and its test was re-run unchanged on 5adff78bb168. Every other check ran on 5adff78bb168 or release jars.

Each independent check and its verdict
CheckVerdictWhat it found
finder reproducedOwn test (abcdefghij, lowered to 3) fails on main (assertions on and off), 10.6.0 and 9.1.0; a probe shows the IndexOutOfBoundsException variant.
independent reproducer realOwn test (hello, lowered to 2), written from the claim only, fails on main (on, off), 10.6.0, 10.5.2 and 9.12.3; 4.10.0 fails, 4.9.0 passes; UAX29URLEmailTokenizer fails too.
cross-model review, refute lens (OpenAI GPT-5.6, executed) real (high)Own test: the shrink at end of input fails, the grow and shrink-before-reset controls pass, with assertions on and off. Intended-behaviour argument rated weak.
devil's-advocate verifier real, LowLifecycle probe: fails only between two incrementToken() calls with more text buffered than the new length; ClassicTokenizer works.
adjudication, separate re-runs, audit and final repro check confirmed (high), LowFresh-seed re-runs fail on main (on, off) and 10.6.0; one minimal deterministic test.
this doc's fix check reproduced; fix A validatedGradle without the fix fails (on, off); with fix A, regenerated from .jflex, the repro and every existing standard and email analysis test pass (140 tests in all, the repro and the sibling check included; 1 skipped). The repro also fails on the 10.6.0 jars.

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

Finding record

LFV-1202, snapshot of 2026-11-06

Registry title
StandardTokenizer.setMaxTokenLength lowered between two incrementToken() calls shrinks the scanner buffer under the scanner's read positions, so the next incrementToken() throws ArrayIndexOutOfBoundsException or IndexOutOfBoundsException instead of continuing (UAX29URLEmailTokenizer the same; since 4.10.0, LUCENE-5897)
Area
analysis / StandardTokenizer (JFlex scanner buffer resize: StandardTokenizer.setMaxTokenLength, StandardTokenizerImpl.setBufferSize)
Priority
Low, rank 778: Lowering a StandardTokenizer's or UAX29URLEmailTokenizer's max token length between two incrementToken() calls of one stream throws an out-of-bounds exception, in every release since 4.10.0. Analyzers and factories set the length before reset(), the failure is loud and recoverable with no wrong tokens, and no real code was found doing it, so it is contrived.
Impact class
minor
Impact note
Only code that holds a StandardTokenizer (or UAX29URLEmailTokenizer) directly and lowers its max token length between two incrementToken() calls of one stream, below the input the scanner has already buffered, is hit: the next incrementToken() throws ArrayIndexOutOfBoundsException or IndexOutOfBoundsException and the rest of that stream is not tokenized; StandardAnalyzer, UAX29URLEmailAnalyzer and the tokenizer factories set the length before reset(), so analyzer users are not affected; -ea and -da, any input size, every release from 4.10.0 (and 4.9.1) to 10.6.0 and main; the failure is loud, close() plus a new reader recovers, and no wrong tokens were seen.
Summary
You tokenize 'hello' with a StandardTokenizer, read its only token, and then call setMaxTokenLength(2), a value inside the documented range [1, 1M], which the setter accepts. You expect the next incrementToken() to return false, because no input is left (it does without the call, or when the length is raised). Instead it throws ArrayIndexOutOfBoundsException 'arraycopy: last source index 5 out of bounds for char[2]' from StandardTokenizerImpl.zzRefill: the setter also resizes the scanner's buffer, and setBufferSize copies only min(old, new) chars but keeps the scanner's read positions, which now point past the end of the smaller buffer.
Introduced
acf9242850739874c1f30ad7daf262081dac900f (trunk, svn r1619730, 'LUCENE-5897, LUCENE-5400: JFlex-based tokenizers StandardTokenizer and UAX29URLEmailTokenizer tokenize extremely slowly ...' by Steven Rowe (sarowe)), which made StandardTokenizer.setMaxTokenLength, until then a plain field assignment, call the new StandardTokenizerImpl.setBufferSize, and gave UAX29URLEmailTokenizer the same pair; merged as 86dcbcebf2b (branch_4x), 33204ddd895 (lucene_solr_4_10, shipped in 4.10.0) and 690e43b5cae (lucene_solr_4_9, shipped in 4.9.1); issues LUCENE-5897 = #6959 and LUCENE-5400 = #6463 (JIRA era, no pull request; the discussion never mentions a call during a stream) [exec: 4.9.0 passes, 4.9.1 and 4.10.0 fail; read: git log -S 'scanner.setBufferSize' over all refs, git blame -w -C -M, git tag --contains] (2014-08-22 (author date = commit date of acf92428507, 86dcbcebf2b and 33204ddd895; the 4.9.1 merge 690e43b5cae is 2014-09-17); first release 4.10.0 (release tag commit 2014-09-02))
First affected version
4.10.0 [exec] (the later 4.9.1 bugfix release, tag commit 2014-09-21, received the same change and fails too [exec]; 4.0.0 to 4.9.0 pass [exec])
Affected versions
4.9.1 and every release from 4.10.0 through 10.6.0 (4ae946e5a9b): 114 releases, each executed with the same steps (the one-test repro unchanged on 9.1.0 and later, the same steps adapted to the older APIs before that) and each failing with the same ArrayIndexOutOfBoundsException at StandardTokenizerImpl.zzRefill; main 5adff78bb168 with -ea and -da [exec]. Not affected: 4.0.0 to 4.9.0 (17 releases executed, all pass) [exec]. UAX29URLEmailTokenizer (analysis-common), which got the identical setter and setBufferSize in the same commit, fails the same way on main [exec, the independent reproduction and the intended-behaviour check probes, -da]; ClassicTokenizer, whose setter only assigns the field, is not affected [exec, the intended-behaviour check probe]. Not fixed upstream: upstream main 1c2072f02843 (author date 2026-11-02) has no change to StandardTokenizer.java after #16063 (author date 2026-05-15) or to StandardTokenizerImpl.java after #15393 (2025-11-03) [read, 2026-11-03]. Not new in 10.6.
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 intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
Found by
semi-formal: proof-obligation audit
Upstream and prior reports
none found (2026-11-03: 127 logged queries and reads: a scripted prior-report search over apache/lucene issues, PRs and commits, Elasticsearch, OpenSearch and Solr, the dev@, java-user@, issues@ and commits@ lists, the JIRA archive and the web; 43 manual GitHub searches incl. Lucene.NET, JFlex and the PRs created or updated since 2026-11-01; LUCENE, SOLR and LUCENENET JQL; Stack Overflow; the changed files of all 409 open apache/lucene PRs, of which only #16949 touches StandardTokenizerImpl.java, in one yyreset javadoc line; commits by author date to the fix-site files: none after 5adff78bb168). Nearest items, none of them this bug: #6959 (LUCENE-5897, the introducing change), #6463 (LUCENE-5400), #7740 (LUCENE-6682, copy cost and the 1M limit) and its 2015 java-user thread 'StandardTokenizer#setMaxTokenLength', #8811 (LUCENE-7760, javadoc wording), #8050 (a 2016 comment that non-test callers set the buffer size right after construction), open #14645 (jflex 1.9 buffer-size investigation; does not mention this case), #7714 (LUCENE-6656, another StandardTokenizer state defect). Re-checked 2026-11-03: 'setMaxTokenLength' still 11 issues (the only one the earlier searches had not listed, #6847 LUCENE-5785, is a CharTokenizer feature request, not this bug), 'setBufferSize' 10, no StandardTokenizer or UAX29URLEmailTokenizer item created since 2026-11-01, the PRs created since then (#16959-#16966) unrelated; upstream main 1c2072f02843.
Lucene commit tested
main 5adff78bb168 (2026-11-02) through gradle on 2026-11-03 in a scratch worktree, seed DEADBEEF: the repro without the fix (assertions on and off: fails; the same steps on UAX29URLEmailTokenizer: fails) and with fix A, regenerated from the .jflex sources by the build's JFlex (assertions on and off: passes; assertions on, every core org.apache.lucene.analysis.standard and analysis-common org.apache.lucene.analysis.email test: 140 tests, 0 failures, 1 skipped), plus the generated-sources checksum checks and checkGoogleJavaFormat; a lifecycle probe on stock and fix A (assertions off), and, in the fact check, with fix B's check shadowed in; the repro on the 10.6.0 jars (assertions off: fails). The hunt ran the same steps on 131 releases, 4.0.0 to 10.6.0 (114 fail, 17 pass). Upstream main 1c2072f02843 was read on 2026-11-03. Only Linux aarch64 with OpenJDK 25.0.3 was run.

Lucene and upstream sources

Finding record
  • lucene/core/src/java/org/apache/lucene/analysis/standard/StandardTokenizer.java:86-94 (at 5adff78bb168) (setMaxTokenLength javadoc: tokens longer than the length are chopped; only IllegalArgumentException outside [1, MAX_TOKEN_LENGTH_LIMIT]), :95-105 (range check, then 'if (length != maxTokenLength) { maxTokenLength = length; scanner.setBufferSize(length); }', :102-103), :157 (incrementToken calls scanner.getNextToken), :188-196 (close() and reset() call scanner.yyreset(input))
  • lucene/core/src/java/org/apache/lucene/analysis/standard/StandardTokenizerImpl.java:713-718 (at 5adff78bb168) (setBufferSize: new char[numChars], copies Math.min(zzBuffer.length, ZZ_BUFFERSIZE) chars, keeps every position), :753-759 (zzRefill: 'if (zzStartRead > 0)' System.arraycopy(zzBuffer, zzStartRead, zzBuffer, 0, zzEndRead - zzStartRead), line 759), :995 and :1014 (getNextToken: Character.codePointAt(zzBufferL, zzCurrentPosL, zzEndReadL)), :1004 (zzRefill call), :832-840 (yyreset: yyResetPosition(), buffer reallocated only if larger than ZZ_BUFFERSIZE)
  • lucene/core/src/java/org/apache/lucene/analysis/standard/StandardTokenizerImpl.jflex:151-156 (at 5adff78bb168) (setBufferSize source, byte-identical body since acf92428507)
  • lucene/analysis/common/src/java/org/apache/lucene/analysis/email/UAX29URLEmailTokenizer.java:100-110 (at 5adff78bb168) (identical setter: scanner.setBufferSize(length) at :108); UAX29URLEmailTokenizerImpl.java:33585-33590 (identical setBufferSize)
  • lucene/core/src/java/org/apache/lucene/analysis/standard/StandardAnalyzer.java:71 (at 5adff78bb168) (the analyzer's setMaxTokenLength), :85-95 (createComponents: the setReader lambda calls src.setMaxTokenLength at :92 before src.setReader at :93, so the length is set before reset())
  • two Lucene main checkouts: sha1 of StandardTokenizer.java (94eae581b7ab), StandardTokenizerImpl.java (fc9832cd7539) and UAX29URLEmailTokenizer.java (1fcabb9fbfe4) identical at ea57360912ed and 5adff78bb168 (checked 2026-11-03)
The finding in 30 seconds
  • lucene/core/src/java/org/apache/lucene/analysis/standard/StandardTokenizer.java:86-105, (at 5adff78bb168) :152-175, :188-198
  • lucene/core/src/java/org/apache/lucene/analysis/standard/StandardTokenizerImpl.java:51, (at 5adff78bb168) :621, :713-718, :753-772, :832-840, :973-1004; StandardTokenizerImpl.jflex:148-157
  • lucene/analysis/common/src/java/org/apache/lucene/analysis/email/UAX29URLEmailTokenizer.java:100-110; (at 5adff78bb168) UAX29URLEmailTokenizerImpl.java:33583-33590
  • lucene/analysis/common/src/java/org/apache/lucene/analysis/classic/ClassicTokenizer.java:83-89 (at 5adff78bb168)
Who is affected
  • lucene/core/src/java/org/apache/lucene/analysis/standard/StandardAnalyzer.java:85-95; (at 5adff78bb168) lucene/analysis/common/src/java/org/apache/lucene/analysis/email/UAX29URLEmailAnalyzer.java:89-100
  • lucene/core/src/java/org/apache/lucene/analysis/standard/StandardTokenizerFactory.java:58-62; (at 5adff78bb168) lucene/analysis/common/src/java/org/apache/lucene/analysis/email/UAX29URLEmailTokenizerFactory.java:59-63
Why it looks unintended
  • lucene/core/src/java/org/apache/lucene/analysis/standard/StandardTokenizer.java:86-105; (at 5adff78bb168) lucene/core/src/java/org/apache/lucene/analysis/TokenStream.java:49-62
  • lucene/core/src/test/org/apache/lucene/analysis/standard/TestStandardAnalyzer.java:100-108; (at 5adff78bb168) lucene/analysis/common/src/test/org/apache/lucene/analysis/email/TestUAX29URLEmailTokenizer.java:65-71, :110-112
  • lucene/analysis/common/src/java/org/apache/lucene/analysis/classic/ClassicAnalyzer.java:78; (at 5adff78bb168) ClassicTokenizer.java:83-89
Suggested fix
  • lucene/core/src/java/org/apache/lucene/analysis/standard/StandardTokenizer.java:163-173; (at 5adff78bb168) StandardTokenizerImpl.java:832-840
Prior reports
  • https://github.com/apache/lucene/issues/6959, https://github.com/apache/lucene/issues/7740, https://github.com/apache/lucene/issues/8811, https://github.com/apache/lucene/issues/14645, https://github.com/apache/lucene/pull/16949
Diagrams
  • lucene/core/src/java/org/apache/lucene/analysis/standard/StandardTokenizer.java:101-104; (at 5adff78bb168) StandardTokenizerImpl.java:51, :713-718, :756-759, :980

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

Repro files

About the diagrams

The diagram follows the repro's input and the executed outcome on main (StandardTokenizer.java:101-104, StandardTokenizerImpl.java:713-718, :756-759).

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.