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.
- A consumer calls
setReader(...), thenreset(), thenincrementToken()until it returns false, thenend()andclose(). StandardTokenizerreads 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.setMaxTokenLength(n)accepts anynfrom 1 to 1,048,576 and resizes the buffer tonchars at once.
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.- Tokenize
hellowith aStandardTokenizer(default max token length 255):setReader,reset(), and the firstincrementToken()returns the only token,hello. All 5 characters are now in the tokenizer's buffer. - Call
setMaxTokenLength(2). The value is inside the documented range, 1 to 1,048,576, and the call returns normally. - 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. - 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. - With text still unread (
aaaaaaaaaa bbbbbbbbbb, lowered to 4 after the first word) it throwsIndexOutOfBoundsExceptionandbbbbbbbbbbis never returned. Setting the length beforereset()or between streams works.
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 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). - The resize keeps the positions (the fix site).
setBufferSizeallocatesnew char[numChars], copiesMath.min(zzBuffer.length, numChars)chars and leaveszzStartRead,zzMarkedPos,zzCurrentPosandzzEndReadunchanged (StandardTokenizerImpl.java:713-718; sourceStandardTokenizerImpl.jflex:151-156). - The repro. Reading
helloleftzzEndRead = 5andzzMarkedPos = 5; the new buffer ischar[2]. The nextgetNextTokensetszzStartRead = 5(:980), finds no buffered char left and callszzRefill(: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. - With unread text left,
getNextTokenreadsCharacter.codePointAt(zzBuffer, zzCurrentPos, zzEndRead)withzzEndReadpast the array (:995):IndexOutOfBoundsExceptionwith a null message, and the rest of the stream is lost. - Why other call orders work. Before
reset():reset()callsyyreset(StandardTokenizer.java:193-198), which resets every position (StandardTokenizerImpl.java:832-840). Right afterreset(): nothing is buffered yet. AfterincrementToken()has returned false, or afterend(): 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()andreset()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
setMaxTokenLengthbetween twoincrementToken()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.
StandardAnalyzersets the length right after construction and again in itssetReaderhook, beforereset()(StandardAnalyzer.java:87,:92-93);UAX29URLEmailAnalyzerdoes the same (UAX29URLEmailAnalyzer.java:92,:97-98); both factories set it increate(). CallingStandardAnalyzer.setMaxTokenLengthmid-stream only changes a field that the nexttokenStream()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.
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.
/*
* 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();
}
}
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]atStandardTokenizerImpl.zzRefill(StandardTokenizerImpl.java:759).
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.
| lucene | main 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 |
|---|---|
| jdk | OpenJDK 25.0.3 (build 25.0.3+9-LTS) |
| os | Linux aarch64 (nothing platform-specific) |
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
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)- 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 intoIllegalStateException.
The full argument149 words
- Contract. The setter's javadoc names one failure,
IllegalArgumentExceptionoutside[1, MAX_TOKEN_LENGTH_LIMIT](StandardTokenizer.java:86-94). TheTokenStreamworkflow (TokenStream.java:49-62) does not forbid reconfiguring betweenincrementToken()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. Beforeacf92428507(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 anIllegalStateException. - 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.
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, seedDEADBEEF: without the fix the repro fails (assertions on and off), and so do the same steps onUAX29URLEmailTokenizer(a check file, not part of the repro). - The
.jflexedit was regenerated with the build's own JFlex (regenerateStandardTokenizer,regenerateUAX29URLEmailTokenizer); each regenerated.javadiffers 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.standardtest and every analysis-commonorg.apache.lucene.analysis.emailtest pass (includingTestStandardAnalyzer,TestUAX29URLEmailTokenizerand the sibling check): 140 tests, 0 failures, 1 skipped. The generated-sources checksum checks and:lucene:core:checkGoogleJavaFormatpass. The analysis-common format check (for the regeneratedUAX29URLEmailTokenizerImpl.java) and the full module suites were not run, and only OpenJDK 25.0.3 on Linux aarch64 was used.
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.
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 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.
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
TokenStreamworkflow as obligations: an accepted call must keep the scanner's positions inside its buffer.setBufferSizekeeps 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 theIndexOutOfBoundsExceptionvariant 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.
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.
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues, PRs and commits (apache/lucene incl. the migrated JIRA archive) | 2026-11-03; re-checked the same day | a 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-01 | no report; #6959, #7740 and #8811 are the same setter on other topics |
| Open PRs | 2026-11-03 | the changed files of all 409 open apache/lucene PRs | only #16949 touches StandardTokenizerImpl.java, in one javadoc line |
| Mailing lists, downstream, web | 2026-11-03 | dev@, java-user@, issues@ and commits@; Elasticsearch, OpenSearch, Solr, Lucene.NET and JFlex trackers; Stack Overflow; web queries | nothing about this bug |
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.
| Version or commit | Verdict | Result |
|---|---|---|
| 4.0.0 to 4.9.0 | 17 releases, same steps | pass: the second incrementToken() returns false |
| 4.9.1, 4.10.0 to 4.10.4 | same steps | fail, the same AIOOBE |
| 5.0.0 to 9.12.3 | same steps | fail, the same AIOOBE |
| 10.0.0 to 10.6.0 | same steps; the repro itself on 10.6.0 | fail, the same AIOOBE (10.6.0 also with assertions off) |
| main 5adff78bb168 | JUnit, -ea and -da | fail, AIOOBE at zzRefill:759 |
| main 5adff78bb168 + fix A | gradle | passes; 140 tests incl. the repro: 0 failures, 1 skipped |
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.
| Check | Verdict | What it found |
|---|---|---|
| finder | reproduced | Own 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 | real | Own 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, Low | Lifecycle 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), Low | Fresh-seed re-runs fail on main (on, off) and 10.6.0; one minimal deterministic test. |
| this doc's fix check | reproduced; fix A validated | Gradle 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. |
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 417 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,297 words
- 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, seedDEADBEEF: the repro without the fix (assertions on and off: fails; the same steps onUAX29URLEmailTokenizer: fails) and with fix A, regenerated from the.jflexsources by the build's JFlex (assertions on and off: passes; assertions on, every coreorg.apache.lucene.analysis.standardand analysis-commonorg.apache.lucene.analysis.emailtest: 140 tests, 0 failures, 1 skipped), plus the generated-sources checksum checks andcheckGoogleJavaFormat; 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 main1c2072f02843was read on 2026-11-03. Only Linux aarch64 with OpenJDK 25.0.3 was run.
- 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-198lucene/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-157lucene/analysis/common/src/java/org/apache/lucene/analysis/email/UAX29URLEmailTokenizer.java:100-110; (at 5adff78bb168) UAX29URLEmailTokenizerImpl.java:33583-33590lucene/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-100lucene/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-62lucene/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-112lucene/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
- TestStandardTokenizerMaxTokenLengthMidStream.java (lucene/core, package org.apache.lucene.analysis.standard, 1 test):
repro/LFV-1202/TestStandardTokenizerMaxTokenLengthMidStream.java(35 lines)
The diagram follows the repro's input and the executed outcome on main (StandardTokenizer.java:101-104, StandardTokenizerImpl.java:713-718, :756-759).
assets/LFV-1202/bug.png(417 KB)
![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.](../assets/LFV-1202/bug.png)