All findings · Lucene finding · LFV-438 · uh-max-maxpassages-queue-capacity-overflow

UnifiedHighlighter with maxPassages = Integer.MAX_VALUE throws a message-less IllegalArgumentException for every document that has a match

Pass Integer.MAX_VALUE, a conventional "no limit" value, as maxPassages to the UnifiedHighlighter, and every document with a match throws an IllegalArgumentException with no message instead of returning its snippet. The passage queue's starting size is Math.min(64, maxPassages + 1), and that + 1 wraps around to a negative number.

Summary

Priority
Low, rank 639UnifiedHighlighter throws a message-less IAE on every matching document for maxPassages = Integer.MAX_VALUE, which is loud and caught in testing but uses the usual no-limit idiom.
Impact
wrong error
Since
6.3.0 (2016, the first release of the UnifiedHighlighter)introduced 2016-10-04 · over 10 years
Affected
every release from 6.3.0 to 10.6.0, and main
Status
documented
Found by
a scan of which public methods check their arguments, then a search for overflowing + 1 arithmetic

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

The highlighter keeps the best passages in a java.util.PriorityQueue whose starting size is Math.min(64, maxPassages + 1). The 64 keeps big limits cheap; the + 1 matches the one extra passage it holds for a moment. For Integer.MAX_VALUE the + 1 overflows to a negative size, and the JDK refuses it.

Background: passages and maxPassages

  1. The UnifiedHighlighter cuts a field's text into passages (by default, sentences), scores each passage that contains a query match, and joins the best ones into the snippet.
  2. maxPassages is how many of those top-scoring passages a snippet may use. highlight(field, query, topDocs, maxPassages), highlightFields(fields, query, topDocs, int[] maxPassages) and highlightWithoutSearcher(field, query, content, maxPassages) take it as a plain int with no documented range.
Summary card in four panels. 1: the repro calls highlightWithoutSearcher(body, body:test, a test, n); n = 1000 gives a <b>test</b>, and n = Integer.MAX_VALUE, expected to give the same, throws an IllegalArgumentException with no message. 2: line 143 sizes the queue with Math.min(64, maxPassages + 1): 1000 and 2147483646 give 64, but 2147483647 + 1 wraps to -2147483648, and the JDK PriorityQueue constructor throws when the capacity is below 1. 3: expected vs actual on main: 1, 64, 1000 and MAX_VALUE - 1 work; MAX_VALUE throws for a text with a match; a text without a match is summarized; highlightFields with {1, MAX_VALUE} throws; with line 143 as maxPassages >= 64 ? 64 : maxPassages + 1 the repro passes. 4: why it is a bug: no documented upper bound, a wrapped number rejected with no message, and a multi-field call loses all its snippets.
The call with maxPassages = Integer.MAX_VALUE, how line 143 turns 2,147,483,647 + 1 into -2,147,483,648, the JDK's size check, expected vs actual for each call, and why it is a bug. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. You highlight the text a test for the query body:test with highlightWithoutSearcher("body", query, "a test", 1000) and get a <b>test</b>.
  2. You pass Integer.MAX_VALUE (2,147,483,647) instead, to mean "no limit". You expect the same a <b>test</b>: the javadoc sets no upper bound, and 1, 3, 64, 1000 and 2,147,483,646 all give it.
  3. You get an IllegalArgumentException with no message, thrown by the java.util.PriorityQueue constructor. With highlightFields and per-field limits {1, Integer.MAX_VALUE} the whole call fails, so the field that asked for one passage loses its snippet too.
  4. Why: the queue's starting size is Math.min(64, maxPassages + 1). 2,147,483,647 + 1 wraps to -2,147,483,648, Math.min picks it, and the JDK rejects a size below 1. A document without a match never builds the queue (since 7.3.0), so it still gets its summary.

Who is affected

Anyone who passes Integer.MAX_VALUE as "no limit", directly or by forwarding a client's snippet count. Every document with a match fails, and a multi-field call loses all its snippets. The error has no message and is not the documented one. It fails loudly on the first matching document; no wrong results, hang or index damage was observed.

Mechanism, terms and numbers605 words

The UnifiedHighlighter keeps a field's best maxPassages passages in a java.util.PriorityQueue, created in FieldHighlighter.highlightOffsetsEnums with the initial capacity Math.min(64, maxPassages + 1) (FieldHighlighter.java:141-143 on main 645d4fe14a20). The 64 cap keeps the eagerly allocated initial array small for large limits; the + 1 matches the queue briefly holding one passage more than the limit (why it was written this way is not recorded). For maxPassages = Integer.MAX_VALUE, a conventional "no limit" value (Lucene's own javadocs use it that way, in this module too: MatchRegionRetriever, FastVectorHighlighter), the + 1 wraps to Integer.MIN_VALUE, Math.min returns it, and the JDK constructor throws IllegalArgumentException with no message.

With the stock highlighter that happens for every document whose field has a match; it was executed through highlight, highlightFields and highlightWithoutSearcher, with offsets in postings and with re-analysis, with assertions on and off; in a multi-field call the other fields lose their snippets too. Every limit from 1 to Integer.MAX_VALUE - 1 gives a valid capacity and every one tested works; since 7.3.0, documents without a match are summarized normally. The expression has been there since the UnifiedHighlighter was added (LUCENE-7438, 2016); every release from 6.3.0 to 10.6.0 and main is affected. A one-line overflow-safe clamp fixes it.

The code (main 645d4fe14a20, lucene/highlighter/.../uhighlight/)

  • Entry points. highlight(field, query, topDocs, maxPassages) wraps the value in an array and calls highlightFields (UnifiedHighlighter.java:766-770), which ends in highlightFieldsAsObjects (:858). That method checks only "fieldsIn must not be empty" and "invalid number of maxPassagesIn" (:892-897), plus that a searcher is set (:898-902), never the values, and builds one FieldHighlighter per field with its own count (:922-924). highlightWithoutSearcher does the same for one string (:1096-1107).
  • No match, no queue. highlightFieldForDoc calls highlightOffsetsEnums (FieldHighlighter.java:86), which returns new Passage[0] when there is no term occurrence (:137-139); the summary path then sizes its list with Math.min(maxPassages, 10) (:112). So Integer.MAX_VALUE works for documents without a match.
  • The wrap. With a match, :141-143 builds new PriorityQueue<>(Math.min(64, maxPassages + 1), comparator). 2147483647 + 1 = -2147483648 and Math.min(64, -2147483648) = -2147483648; for 2147483646 the expression is Math.min(64, 2147483647) = 64.
  • The throw. JDK 25 PriorityQueue(int, Comparator): if (initialCapacity < 1) throw new IllegalArgumentException();, then new Object[initialCapacity] (PriorityQueue.java:173-175). The queue grows on demand, so 64 is only a starting size.
  • The + 1. maybeAddPassage (FieldHighlighter.java:201-224) offers the new passage and polls the lowest when size() > maxPassages (:215-217), so the queue briefly holds maxPassages + 1 entries, which the + 1 matches (the original rationale is not recorded).

What each call does on main

On 645d4fe14a20: the minimal test and a probe (highlightWithoutSearcher with 1, 3, 64, 1000, MAX_VALUE - 1, MAX_VALUE; a text without a match; highlightFields with {1, MAX_VALUE}). On ea57360912ed, whose highlighter tree is identical: an independent reproducer's six tests (offsets in postings, re-analysis, highlightWithoutSearcher, {1, MAX_VALUE}, two controls with 3, 63, 64, 1000, MAX_VALUE / 2, MAX_VALUE - 1).

callmain todayexpected
maxPassages 1, 63, 64, 1000, MAX_VALUE / 2, MAX_VALUE - 1, document with a matchthe snippetsame
maxPassages = MAX_VALUE, document without a matchthe summarysame
maxPassages = MAX_VALUE, document with a match (highlight with offsets in postings or re-analysis, highlightWithoutSearcher)IllegalArgumentException, no messagethe same snippet, or a clear rejection
highlightFields with {1, MAX_VALUE}IllegalArgumentException; both fields' snippets lostboth fields highlighted, or a clear rejection
Exposure and likelihood207 words
  • Who is hit. Code that calls highlight, highlightFields or highlightWithoutSearcher (or a subclass through highlightFieldsAsObjects) with maxPassages = Integer.MAX_VALUE, directly or by forwarding a client's snippet count without an upper bound.
  • Default vs opt-in. Default highlighter settings; the trigger is the single argument value Integer.MAX_VALUE. Any field with a match: by reading, every offset source reaches the same line, and offsets from postings and from re-analysis were executed.
  • What goes wrong. The call throws on the first matching document, so the request gets no snippets; in a multi-field call all fields lose theirs. The exception has no message and is not the documented one (the only documented IllegalArgumentException is for fields indexed without offsets), so it points the caller at the wrong cause.
  • Scale. None needed: one document, one sentence. It failed with every recorded seed, assertions on and off.
  • Workaround. Pass Integer.MAX_VALUE - 1 or any realistic bound.
  • How likely. Rare: Integer.MAX_VALUE is a conventional "no limit" value (in the same module, MatchRegionRetriever asks for Integer#MAX_VALUE "for all hits to be considered" and FastVectorHighlighter documents its default phrase limit as "unlimited (Integer.MAX_VALUE)"), but the failure is loud on the first matching document, and the recorded searches found no report in ten years.

Minimal repro

One JUnit method on the public API: highlight a test for body:test with maxPassages = 1000, then with Integer.MAX_VALUE, and compare. No index, no searcher, no random() calls; it fails with assertions on and off and with every recorded seed.

TestUnifiedHighlighterMaxValueMaxPassages.java (lucene/highlighter, package org.apache.lucene.search.uhighlight, 1 test)41 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.search.uhighlight;

import org.apache.lucene.analysis.standard.StandardAnalyzer;
import org.apache.lucene.index.Term;
import org.apache.lucene.search.Query;
import org.apache.lucene.search.TermQuery;
import org.apache.lucene.tests.util.LuceneTestCase;

public class TestUnifiedHighlighterMaxValueMaxPassages extends LuceneTestCase {

  public void testMaxValueMaxPassages() throws Exception {
    UnifiedHighlighter highlighter =
        UnifiedHighlighter.builderWithoutSearcher(new StandardAnalyzer()).build();
    Query query = new TermQuery(new Term("body", "test"));
    Object expected = highlighter.highlightWithoutSearcher("body", query, "a test", 1000);
    // expected: the same snippet as maxPassages = 1000; actual: a message-less IllegalArgumentException
    try {
      assertEquals(
          expected,
          highlighter.highlightWithoutSearcher("body", query, "a test", Integer.MAX_VALUE));
    } catch (IllegalArgumentException e) {
      throw new AssertionError("expected " + expected + " for maxPassages = MAX_VALUE, got " + e, e);
    }
  }
}
Command
cp $REPRO/TestUnifiedHighlighterMaxValueMaxPassages.java $LUCENE/lucene/highlighter/src/test/org/apache/lucene/search/uhighlight/
cd $LUCENE && ./gradlew :lucene:highlighter:test --tests org.apache.lucene.search.uhighlight.TestUnifiedHighlighterMaxValueMaxPassages -Ptests.seed=DEADBEEF
Expected
a <b>test</b>, the same snippet that maxPassages = 1000 gives.
Observed
IllegalArgumentException with no message, from the java.util.PriorityQueue constructor at FieldHighlighter.java:143.

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

More files, environment, full output238 words

Copy the test into lucene/highlighter/src/test/org/apache/lucene/search/uhighlight/ of a Lucene checkout and run it with gradle (command below). The IllegalArgumentException is caught only to rethrow it as the cause of an AssertionError that states expected vs actual; the cause keeps the PriorityQueue and FieldHighlighter frames. With the clamp fix it passes unchanged, so it doubles as the regression test.

Before using it in a PR, run ./gradlew tidy: google-java-format re-wraps the comment and the throw line (both over 100 columns); the code does not change, and the re-wrapped file passes checkGoogleJavaFormat.

Executed on main 645d4fe14a20: gradle in a clean scratch worktree (fails), and the standalone runner on the stock main jars with assertions on and off (fails); it also fails on the 10.6.0, 10.5.2 and 9.12.3 release jars.

Environment

lucenemain 645d4fe14a20 (11.0.0-SNAPSHOT): fails; also fails on the 10.6.0, 10.5.2 and 9.12.3 release jars
jdkOpenJDK 25.0.3 (build 25.0.3+9-LTS)
osLinux aarch64 (plain int arithmetic, nothing platform-specific)
Command (environment)
cp $REPRO/TestUnifiedHighlighterMaxValueMaxPassages.java $LUCENE/lucene/highlighter/src/test/org/apache/lucene/search/uhighlight/
cd $LUCENE && ./gradlew :lucene:highlighter:test --tests org.apache.lucene.search.uhighlight.TestUnifiedHighlighterMaxValueMaxPassages -Ptests.seed=DEADBEEF

Observed output

# gradle, main 645d4fe14a20, -Ptests.seed=DEADBEEF
TestUnifiedHighlighterMaxValueMaxPassages > testMaxValueMaxPassages FAILED
    java.lang.AssertionError: expected a <b>test</b> for maxPassages = MAX_VALUE, got java.lang.IllegalArgumentException
        at __randomizedtesting.SeedInfo.seed([DEADBEEF:C5AAB44B573F209A]:0)
        at org.apache.lucene.search.uhighlight.TestUnifiedHighlighterMaxValueMaxPassages.testMaxValueMaxPassages(TestUnifiedHighlighterMaxValueMaxPassages.java:38)

        Caused by:
        java.lang.IllegalArgumentException
            at java.base/java.util.PriorityQueue.<init>(PriorityQueue.java:174)
            at org.apache.lucene.search.uhighlight.FieldHighlighter.highlightOffsetsEnums(FieldHighlighter.java:143)
            at org.apache.lucene.search.uhighlight.FieldHighlighter.highlightFieldForDoc(FieldHighlighter.java:86)
            at org.apache.lucene.search.uhighlight.UnifiedHighlighter.highlightWithoutSearcher(UnifiedHighlighter.java:1107)
            at org.apache.lucene.search.uhighlight.TestUnifiedHighlighterMaxValueMaxPassages.testMaxValueMaxPassages(TestUnifiedHighlighterMaxValueMaxPassages.java:36)

# standalone runner on the built or released jars, seed DEADBEEF
main 645d4fe14a20       -ea / -da   tests_run=1 failures=1   same cause
10.6.0, 10.5.2, 9.12.3  -ea         tests_run=1 failures=1   same cause

Why it looks unintended

  • The javadoc sets no upper bound on maxPassages; the only documented IllegalArgumentException is for fields without offsets.
  • The 64 cap keeps big limits cheap: every value from 1 to 2,147,483,646 gets a valid size, and Integer.MAX_VALUE already works for documents without a match.
  • Intended rejection looks different: in the same class, withMaxLength rejects Integer.MAX_VALUE up front, with a message.
The full argument229 words
  • Contract. Every overload documents maxPassages only as "The maximum number of top-N ranked passages [per-field] used to form the highlighted snippets" (UnifiedHighlighter.java:757-758, :819-820, :845-846, :881-882, :1089-1090), with no range, and nothing validates the values (:892-897). The only documented IllegalArgumentException is for fields indexed without offsets (:744-745, :763-764, :793-794, :825-826, :851-852, :887-888).
  • Built for large limits. The cap of 64 keeps a large count from allocating a large queue; every value from 64 to MAX_VALUE - 1 gets capacity 64. The predecessor PostingsHighlighter sized its queue from the raw count and threw OutOfMemoryError ("Requested array size exceeds VM limit") for both MAX_VALUE and MAX_VALUE - 1 (executed on 6.2.1); the cap avoids that for every value except the one where + 1 wraps.
  • Intended rejection looks different. setMaxLength and Builder.withMaxLength reject Integer.MAX_VALUE up front with "maxLength must be < Integer.MAX_VALUE" (:192-196, :355-360); their comment names a different + 1 hazard (BreakIterator.preceding(offset+1) and the offsets-queue sentinel). maxPassages never had such a guard.
  • Tests. No upstream test passes maxPassages = Integer.MAX_VALUE; the uhighlight tests use withMaxLength(Integer.MAX_VALUE - 1) only.
  • History and precedent. LUCENE-7438 (#8490) and its 28 comments never discuss the queue capacity or Integer.MAX_VALUE. LUCENE-10135 (#11172, PR #334, 9.0.0) fixed a similar MAX_VALUE overflow of maxPassageWindow in MatchHighlighter's PassageSelector.
The devil's-advocate check201 words

The strongest case for "works as designed": no contract promises Integer.MAX_VALUE; UnifiedHighlighter is @lucene.experimental and FieldHighlighter @lucene.internal; 2^31 - 1 passages is unattainable (by default a stored value is truncated to maxLength, 10,000 characters, when it is loaded, so a field read from the index has at most 10,000 passages); the classic Highlighter in the same module also rejects maxNumFragments MAX_VALUE and MAX_VALUE - 1 ("maxSize must be >= 0 and < 2147483631", executed); and Elasticsearch and OpenSearch pass MAX_VALUE - 1 as their "all fragments" value.

Why it fails: the classic Highlighter has no bounds check of its own (only Math.max(1, maxNumFragments)); its rejection comes from the range check of Lucene's generic org.apache.lucene.util.PriorityQueue (commented "Throw exception to prevent confusing OOME"), a stable limit with a message that names it. Here every value but one works, and that one fails inside a JDK collection, without a message and only for documents with a match. The experimental tag allows API changes, not an undocumented error from an accepted argument, and the downstream MAX_VALUE - 1 is an unexplained value choice, not a documented limit. Verdict: a defect, low severity; clamping or rejecting with a message both fix it.

Suggested fix

Recommended Overflow-safe cap on the initial queue capacity (one line)

Make the cap overflow-safe: maxPassages >= 64 ? 64 : maxPassages + 1 at FieldHighlighter.java:143, one line. Every value below Integer.MAX_VALUE keeps today's starting size, and Integer.MAX_VALUE gets 64. With it, the minimal test and all 723 highlighter tests pass.

    PriorityQueue<Passage> passageQueue =
        new PriorityQueue<>(
            maxPassages >= 64 ? 64 : maxPassages + 1, // no int overflow for Integer.MAX_VALUE
            (left, right) -> {
All options and trade-offs2 options · 504 words

Two options; the clamp is preferred here for compatibility, and rejecting is also defensible. The clamp was validated on 645d4fe14a20; rejecting the value with a message was not executed. Either way, do not size the queue from maxPassages itself (for example new PriorityQueue<>(maxPassages + 1) or long arithmetic without the cap): java.util.PriorityQueue allocates its initial array eagerly, the predecessor PostingsHighlighter threw OutOfMemoryError that way for both MAX_VALUE and MAX_VALUE - 1, and a downstream engine capped huge fragment counts after the classic Highlighter allocated gigabytes (elastic/elasticsearch#155507). A natural combination: the clamp for this bug, plus a maxPassages < 1 check for the maxPassages = 0 failure (a separate bug, a NullPointerException at FieldHighlighter.java:212).

Overflow-safe cap on the initial queue capacity (one line) (validated by the checks) (recommended)

In FieldHighlighter.highlightOffsetsEnums, compute the initial capacity without overflow. (int) Math.min(64L, maxPassages + 1L) is equivalent.

    PriorityQueue<Passage> passageQueue =
        new PriorityQueue<>(
            maxPassages >= 64 ? 64 : maxPassages + 1, // no int overflow for Integer.MAX_VALUE
            (left, right) -> {

Trade-offs

  • The same initial capacity as today for every value below Integer.MAX_VALUE: maxPassages + 1 up to 63, 64 from 64 to Integer.MAX_VALUE - 1; Integer.MAX_VALUE now gets 64 as well. The initial allocation stays bounded.
  • It does not change maxPassages = 0 (a separate NullPointerException at FieldHighlighter.java:212) or negative values, which stay invalid and are not claimed: the new expression gives n + 1 for them, like the old one. On main (executed, -1, -5 and Integer.MIN_VALUE), a document with a match gets the same message-less exception at line 143, and a document without a match fails earlier in the summary path (new ArrayList<>(Math.min(maxPassages, 10)), line 112) with "Illegal Capacity: <n>".
  • What the validation covers: the positive boundary (Integer.MAX_VALUE) and the existing highlighter suites; no negative input was run with the fix, and below Integer.MAX_VALUE the new expression equals the old one.

What the checks validated

On a clean scratch worktree of 645d4fe14a20 (OpenJDK 25.0.3, seed DEADBEEF): the minimal test fails unmodified and passes with this line; the closest suite TestUnifiedHighlighter (156 tests) and the whole :lucene:highlighter:test (35 suites, 723 tests, including the minimal test) pass with 0 failures (the suite and skip counts read from the JUnit XML of the fact check's identical re-run); checkGoogleJavaFormat passes.

Earlier runs agree: the minimal test passes with the fix shadowed (-ea and -da); the finder's 2 tests and the independent reproducer's 6 tests pass with it, the reproducer's also with the long-arithmetic form.

Reject the value up front with a clear message

Check the counts in highlightFieldsAsObjects and highlightWithoutSearcher, as withMaxLength does for maxLength: == Integer.MAX_VALUE covers this bug; < 1 would also turn the maxPassages = 0 failure into a clear error. Not executed.

Trade-offs

Clear error instead of a JDK one, consistent with withMaxLength. But the conventional "no limit" value becomes an error that callers must work around with Integer.MAX_VALUE - 1, while every other large value stays legal.

Downstream note

By code reading only (nothing run downstream): Solr passes hl.snippets to maxPassages unchecked, so hl.snippets=2147483647 should fail the same way. Elasticsearch rejects counts above 10,000 by default and maps "all fragments" to 2,147,483,646, which works. OpenSearch maps it the same way, passes other counts through (no upper bound found, not fully traced) and keeps its own copy of the line.

How it was found

A static scan listed, for every public method in the core and module jars, which argument checks it makes: highlightFields checks the field list and the array length but never the maxPassages values. A search of such unchecked parameters for + 1 arithmetic found the wrap, and a public-API test confirmed it.

The method in full133 words

Method: a static guard inventory, then a guided search (the finder labelled it semi-formal; the last step was a text search, so it sits at the boundary with non-formal), in a hunt unit on accepted-parameter boundaries, run on the stock main jars at ea57360912ed.

  • A bytecode pass (the JDK ClassFile API) over 27 core and non-sandbox module jars listed 1,231 public or protected entry points whose code reaches an IllegalArgumentException, with each guard's parameters and constants.
  • For UnifiedHighlighter.highlightFields / highlightFieldsAsObjects the only guards are "fieldsIn must not be empty" and "invalid number of maxPassagesIn": the counts themselves are accepted unbounded.
  • Searching the accepted-unbounded parameters for + 1 and * 2 arithmetic found Math.min(64, maxPassages + 1); a hand-written public-API JUnit witnessed the failure, later reduced to the single test shown here.

Prior reports, history and checks

Prior reports

No earlier report found (searched 2026-10-26, re-checked 2026-10-30) in GitHub issues and PRs including the JIRA archive, the mailing lists, downstream projects or the web. Closest, not this bug: #8895 (the shape of the maxPassages API) and LUCENE-10135 (#11172, a different highlighter overflow, fixed in 9.0.0).

Searches4 searches

Related, not this bug: #8895 / LUCENE-7844 (open since 2017: simplify the maxPassages input API; nothing about limits or overflow), #8490 / LUCENE-7438 (the introducing change), #11172 / LUCENE-10135 (a MAX_VALUE overflow of maxPassageWindow in MatchHighlighter's PassageSelector, fixed in 9.0.0), #13103 (open: a different IllegalArgumentException, "indexed without offsets", for interval queries), elastic/elasticsearch#61860 (2020: passes Integer.MAX_VALUE - 1 for "all fragments", with no stated reason and no mention of an error) and elastic/elasticsearch#155507 (2026: caps large fragment counts because of the classic Highlighter's allocations; does not mention the UnifiedHighlighter or the wrap). Coverage limits: GitHub code search was rate-limited, list search matches subjects and leading text only, and private reports cannot be searched.

What was searched, when, and what turned up
WhereWhenHowFound
Search tool (GitHub issues/PRs/commits incl. the JIRA archive, dev@, java-user@, issues@, commits@, downstream, web)2026-10-26two runs: maxPassages with MAX_VALUE, overflow, + 1, initialCapacity; FieldHighlighter, highlightOffsetsEnums, passageQueue; stack-frame strings; hl.snippets and number_of_fragments20 hits classified; none about this bug
GitHub (apache/lucene, apache/lucene-solr, apache/solr, Elasticsearch, OpenSearch, other downstream users) and JIRA2026-10-2671 read-only GitHub searches, 24 JQL queries (LUCENE and SOLR), 34 mailing-list queries; 79 items read#8895, #8490, #11172, #13103, elastic/elasticsearch#61860 and #155507: related, none the same bug
Open PRs2026-10-26changed files of all 411 open apache/lucene PRsno PR touches FieldHighlighter.java
GitHub, re-checks2026-10-30issue/PR search for maxPassages, maxPassages MAX_VALUE, maxPassages overflow, FieldHighlighter PriorityQueue, highlightOffsetsEnums, hl.snippets; open PRs mentioning FieldHighlighter; commits on FieldHighlighter.javaonly older issues, none about this; 0 open PRs; line 143 unchanged on upstream main 2f424fd428da

History

Present since the UnifiedHighlighter was added (LUCENE-7438, 722e8271243 on the default branch and 4b6794368df on branch_6x, 2016-10-04), first released in 6.3.0. Every release from 6.3.0 to 10.6.0 and main fails; before 7.3.0 even documents without a match failed.

Versions tested7 rows

The first highlightOffsetsEnums already had Math.min(64, maxPassages + 1). Later commits did not change the expression: LUCENE-7559 (6.4.0) changed only the comparator body, LUCENE-8145 (9422609a5d4 on the default branch, first released in 8.0.0; its branch_7x twin aa5c3a5c222 in 7.3.0) added the early return for documents without a match before the queue, which narrowed the bug, and LUCENE-9570 (6faa4f98e04, 9.0.0) only reformatted it, which is why plain git blame on main names that commit. Not a regression: no UnifiedHighlighter release ever accepted MAX_VALUE for a matching document, and the predecessor PostingsHighlighter failed for MAX_VALUE and MAX_VALUE - 1 with OutOfMemoryError. Old releases ran on JDK 25.

Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20 affectedthe minimal test fails (gradle; standalone -ea and -da)
10.6.0, 10.5.2, 9.12.3 affectedthe minimal test fails
9.1.0, 9.12.3, 10.0.0, 10.5.2 affectedthe finder's JUnit fails (-ea, and -da on 9.12.3 and 10.5.2)
7.3.0 to 10.5.2 69 releasesa portable main() probe fails for the document with a match; the document without a match is summarized
6.3.0 to 7.2.1 18 releasesthe probe fails for both documents, with and without a match
6.2.1 and older not affectedno UnifiedHighlighter (the probe does not compile)
clamp fix on 645d4fe14a20 not affectedthe minimal test passes, and so do all 723 highlighter tests

Independent checks

An independent reproducer with its own six tests, a devil's advocate, an adjudication and a fresh-seed re-run confirm it; a reviewer from another model family, reading only, agreed on the root cause. The history sweep ran all 87 releases from 6.3.0, and the fix passes the highlighter tests.

Checks and verdicts475 words

The cross-model reviewer could not run Java, so its refute-lens review voted not real at low confidence, on procedure only, while agreeing with the root cause and rating the intended-behaviour argument weak; a re-run of the repro failed. Its intended-behaviour review returned real (medium). The Solr reach is by code reading only.

Each independent check and its verdict
CheckVerdictWhat it found
finder (static guard inventory) candidateIts 2-test JUnit fails 1 of 2 on main (-ea, -da), 10.5.2 and 9.12.3; the control (1000) passes; with the fix it passes 2 of 2.
cross-model review (OpenAI GPT-5.6, refute and intended lenses, reading only) refute: procedural "not real" (low); intended: real (medium)Could not run Java; the lenses agreed on the root cause, found the inputs in contract and rated the intended-behaviour argument weak; a separate run re-ran the repro for each lens (fails).
independent reproducer realOwn 6-test public-API JUnit fails 4 of 6 on main (-ea, -da), 10.5.2 (-ea, -da) and 9.12.3; both controls pass; with the long-arithmetic clamp it passes 6 of 6.
intended-behaviour devil's advocate real (low severity)Built the works-as-designed case and rejected it; its probe records 1, 63, 64, 1000 and 2,147,483,646 working, 2,147,483,647 throwing with a null message, and the classic Highlighter's rejection (from Lucene's util.PriorityQueue, with a message).
adjudication confirmed (high)Re-ran the reproducer's JUnit (-ea, -da: 4 of 6 fail, controls pass); impact class wrong error; security none.
registry re-run keepFresh seeds: the finder's JUnit fails on main (-ea, -da) and passes with the fix; the reproducer's fails 4 of 6 and passes 6 of 6 with the fix.
history sweep and security probe affected since 6.3.0; security nonePortable probe on all 87 releases 6.3.0 to 10.5.2 and main; 1,000 failing calls leave no residue on the highlighter.
minimal-repro audit verifiedThe one test fails on main 645d4fe14a20 (-ea, -da, two seeds) and passes with the fix shadowed.
doc fact checks and diagram check corrections appliedTwo rounds of independent fact checks (one by OpenAI GPT-5.6 each round) and two diagram checks. Round 1 tightened the wording of intent, tested ranges, version qualifiers and the classic-Highlighter argument (one finding rejected with a measurement); round 2 scoped the negative-value remark (now executed), added the stock-highlighter qualifier, cited the "no limit" convention, the default maxLength bound and the kept downstream files, and corrected the seed wording.
this doc reproduced, fix validatedRe-ran the minimal test on main 645d4fe14a20 (standalone runner -ea and -da, and gradle in a clean scratch worktree: fails), validated the clamp in that worktree (the test, TestUnifiedHighlighter and all 723 highlighter tests pass; the format check passes) and re-checked GitHub read-only (nothing new).

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

Finding record

LFV-438, snapshot of 2026-11-06

Registry title
UnifiedHighlighter with maxPassages = Integer.MAX_VALUE throws a message-less IllegalArgumentException for every document that has a match (FieldHighlighter sizes its passage queue with Math.min(64, maxPassages + 1), which wraps)
Area
highlighter / UnifiedHighlighter passage selection (FieldHighlighter.highlightOffsetsEnums passage-queue sizing; maxPassages is never range-checked by UnifiedHighlighter.highlightFieldsAsObjects)
Priority
Low, rank 639: UnifiedHighlighter throws a message-less IAE on every matching document for maxPassages = Integer.MAX_VALUE, which is loud and caught in testing but uses the usual no-limit idiom.
Impact class
wrong error
Impact note
Applications on any release from 6.3.0 through 10.5.2 or on main that call UnifiedHighlighter (highlight, highlightFields, highlightWithoutSearcher; offsets in postings and re-analysis were run; default settings) with maxPassages = Integer.MAX_VALUE, the usual Java 'no limit' value, get a message-less IllegalArgumentException from inside java.util.PriorityQueue for every document that matches (the javadoc documents an IllegalArgumentException only for fields without offsets), and in a multi-field call such as {1, MAX_VALUE} the whole call fails, so the other fields lose their snippets too; every value up to Integer.MAX_VALUE - 1 works, documents without a match are summarized normally, there are no wrong results, no corruption and no hang, no index size is needed, and by code reading (not executed) Solr's hl.method=unified with hl.snippets=2147483647 reaches it, while default Elasticsearch caps number_of_fragments at 10000 and both Elasticsearch and OpenSearch map 0 ('all') to Integer.MAX_VALUE - 1.
Summary
You ask the UnifiedHighlighter for at most Integer.MAX_VALUE passages, meaning 'no limit', and expect the same snippet that 1000 or Integer.MAX_VALUE - 1 gives (or, if the value is not allowed, an IllegalArgumentException that says so). Instead, for every document that has a match, FieldHighlighter.highlightOffsetsEnums sizes its passage queue with Math.min(64, maxPassages + 1); the + 1 wraps to Integer.MIN_VALUE, Math.min returns it, and the java.util.PriorityQueue constructor throws an IllegalArgumentException with no message. With per-field counts such as {1, MAX_VALUE} the field that asked for one passage loses its snippet too, because the whole call throws.
Introduced
722e82712435ecf46c9868137d885484152f749b on master and its branch_6x twin 4b6794368df373df1f68ccf27f7556914efeb95e (LUCENE-7438 'New UnifiedHighlighter' by dsmiley, 2016-10-04; JIRA issue now GitHub #8490, reviewed in apache/lucene-solr#79, which was closed without a GitHub merge; the commits were made directly; FieldHighlighter.java blob 7c8f048d171e is identical in both) [read: git log -L on the line, git log -S'maxPassages + 1', git log -G'Math\.min\(64' and git blame -w -M -C --ignore-rev 6faa4f98e048 in the full-history clone; exec: the history probe fails on 6.3.0, the first release with the API, and does not compile on 6.2.1, where the API is absent]. The first FieldHighlighter.highlightOffsetsEnums already wrote 'new PriorityQueue<>(Math.min(64, maxPassages + 1), ...)'. Later commits did not change the expression: LUCENE-7559 (c51e89014ac / 23b8bb66935, 6.4.0) changed only the comparator body, LUCENE-8145 (9422609a5d4 / aa5c3a5c222, 7.3.0) added an early return for documents without a match before the queue (narrowing the bug), and LUCENE-9570 (6faa4f98e04, 9.0.0) only reformatted it, which is why plain git blame on main names that commit; the predecessor PostingsHighlighter sized the queue new PriorityQueue<>(n, ...) without a cap and failed for both Integer.MAX_VALUE and Integer.MAX_VALUE - 1 with OutOfMemoryError [exec on 6.2.1], so the wrap is new with the UnifiedHighlighter's cap; not a regression within the UnifiedHighlighter (2016-10-04)
First affected version
6.3.0 [exec] (first release with the UnifiedHighlighter, from branch_6x commit 4b6794368df; 7.0.0 is the first release of the master commit and is affected [exec]); 6.2.1 has no org.apache.lucene.search.uhighlight package (the probe does not compile there) [exec]
Affected versions
every release that has the UnifiedHighlighter: all 87 Maven Central releases from 6.3.0 to 10.5.2 (13 in 6.x, 14 in 7.x, 27 in 8.x including 8.8.2-8.11.4, which have no tag in the history clone and are covered by execution only, 21 in 9.x, 12 in 10.x) and main ea57360912ed [exec: the history probe, a public-API main(), fails on every one with -ea, and with -da on 6.3.0, 7.3.0, 9.12.3, 10.5.2 and main; the finder's JUnit fails on 9.1.0, 9.12.3 (-ea and -da), 10.0.0, 10.5.2 (-ea and -da) and main (-ea and -da); the independent reproduction's test fails on 9.12.3, 10.5.2 (-ea and -da) and main (-ea and -da)]; scope differs by release [exec]: in 6.3.0-7.2.1 every highlighted document with non-empty content throws, with or without a match, and from 7.3.0 on (LUCENE-8145) only documents with at least one match throw; not affected: releases up to 6.2.1, which have no UnifiedHighlighter; not fixed upstream (FieldHighlighter.java:143 unchanged on upstream main cf398bfb142c and on branch_10x as of 2026-10-26, no open PR touches FieldHighlighter.java) [read]; release branches after 10.5.2 other than branch_10x were not checked
Status
documented (brief, checks and a per-finding doc exist)
Verification
Confirmed by AI-run checks: an independent repro, a cross-model review that could only read the code (its low-confidence “not real” is procedural), an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
Found by
semi-formal
Upstream and prior reports
none found (2026-10-26, the prior-report record plus the finder's run: 289 recorded queries, namely the scripted prior-report search (34 GitHub issue and PR, 6 commit, 5 downstream, 32 mailing-list queries over dev@, java-user@, issues@ and commits@, 8 JIRA-archive and 8 web queries), the finder's earlier run of the same tool (51 queries), 71 read-only GitHub GraphQL searches over apache/lucene, apache/lucene-solr, apache/solr, elastic/elasticsearch, opensearch-project/OpenSearch, dbmdz/solr-ocrhighlighting, hibernate/hibernate-search, apache/jackrabbit-oak, crate/crate and all of GitHub for exact identifiers and stack-frame strings, 24 JIRA queries (12 SOLR, 12 LUCENE), 34 lists.apache.org queries over the Lucene and Solr lists, and 16 manual reads (GitHub core API, local git history, the 6.2.1 and 6.3.0 sources); the changed files of all 411 open apache/lucene PRs; 20 hits classified and 79 items read, none the same bug); not fixed upstream (FieldHighlighter.java:143 still reads 'Math.min(64, maxPassages + 1),' on upstream main cf398bfb142c, 3 commits after the pinned commit with no highlighter change; the newest commit touching FieldHighlighter.java is dbf0d1426f13 (#16056, 2026-05-14, a toArray change); no open PR touches the file); closest items, not reports: #8895 / LUCENE-7844 (open since 2017; the shape of the maxPassages API, nothing about MAX_VALUE, overflow or limits), #8490 / LUCENE-7438 (the introducing change), #11172 / LUCENE-10135 with PR #334 (fixed 2021, 9.0.0: a MAX_VALUE overflow of maxPassageWindow in the separate MatchHighlighter PassageSelector), elastic/elasticsearch#61860 (2020: passes Integer.MAX_VALUE - 1 for number_of_fragments 0, with no stated reason) and elastic/elasticsearch#155507 (2026-08: caps number_of_fragments because the classic Highlighter allocated gigabytes; does not mention the UnifiedHighlighter or the wrap)
Lucene commit tested
main 645d4fe14a20 (2026-10-28): the minimal test fails under gradle in a clean scratch worktree and with the standalone runner (-ea and -da), and passes with the clamp fix, together with the whole highlighter module (35 suites, 723 tests); the 10.6.0, 10.5.2 and 9.12.3 release jars (the minimal test fails); the hunt's runs on ea57360912ed, whose highlighter tree is identical; and the history sweep over all 87 releases from 6.3.0 to 10.5.2.

Lucene and upstream sources

Finding record
  • lucene/highlighter/src/java/org/apache/lucene/search/uhighlight/FieldHighlighter.java:32 (at ea57360912ed) (@lucene.internal), :83 (try-with-resources OffsetsEnum), :86 (highlightOffsetsEnums call), :109-112 (getSummaryPassagesNoHighlight sizes its list Math.min(maxPassages, 10), so the no-match path works for MAX_VALUE), :137-139 (no term occurrence -> new Passage[0] before the queue exists), :141-143 ('new PriorityQueue<>(Math.min(64, maxPassages + 1), ...)'), :205-217 (maybeAddPassage: offer, then poll when size() > maxPassages, the reason for the + 1)
  • lucene/highlighter/src/java/org/apache/lucene/search/uhighlight/UnifiedHighlighter.java:94 (at ea57360912ed) (@lucene.experimental), :355-360 (Builder.withMaxLength rejects Integer.MAX_VALUE with 'maxLength must be < Integer.MAX_VALUE'; its comment names BreakIterator.preceding(offset+1) and the offsets-queue sentinel, a different quantity), :752-770 (highlight(field, query, topDocs, maxPassages): '@param maxPassages The maximum number of top-N ranked passages used to form the highlighted snippets', no range; the only documented IllegalArgumentException is for fields without offsets), :891-897 (highlightFieldsAsObjects checks 'fieldsIn must not be empty' and 'invalid number of maxPassagesIn', never the values), :919-924 (one new FieldHighlighter per field and call)
  • JDK 25.0.3 java.base/java/util/PriorityQueue.java:169-177 ('if (initialCapacity < 1) throw new IllegalArgumentException()'; eager Object[initialCapacity])
The finding in 30 seconds
  • a Lucene main checkout (645d4fe14a20) lucene/highlighter/src/java/org/apache/lucene/search/uhighlight/FieldHighlighter.java:32, :83, :86, :109-127, :137-139, :141-152, :201-224
  • a Lucene main checkout (645d4fe14a20) lucene/highlighter/src/java/org/apache/lucene/search/uhighlight/UnifiedHighlighter.java:757-770, :819-860, :881-902, :922-924, :1089-1107, :1152-1160
  • JDK 25.0.3 lib/src.zip java.base/java/util/PriorityQueue.java:166-177
Who is affected
  • a Lucene main checkout (645d4fe14a20) lucene/highlighter/src/java/org/apache/lucene/search/uhighlight/UnifiedHighlighter.java:744-745, :763-764, :1096-1107
  • a Lucene main checkout (645d4fe14a20) lucene/highlighter/src/java/org/apache/lucene/search/matchhighlight/MatchRegionRetriever.java:230-232 and .../vectorhighlight/FastVectorHighlighter.java:326-327 (Integer.MAX_VALUE documented as "all hits" / "unlimited"); lucene/core/.../search/TopScoreDocCollectorManager.java:53-54
Why it looks unintended
  • a Lucene main checkout (645d4fe14a20) lucene/highlighter/src/java/org/apache/lucene/search/uhighlight/UnifiedHighlighter.java:94, :192-196, :355-360, :744-745, :757-758, :763-764, :819-820, :845-846, :881-897, :1089-1090
  • a Lucene main checkout (645d4fe14a20) lucene/highlighter/src/java/org/apache/lucene/search/uhighlight/FieldHighlighter.java:32, :141-143
  • a Lucene main checkout (645d4fe14a20) lucene/highlighter/src/test/org/apache/lucene/search/uhighlight/ (grep MAX_VALUE: only withMaxLength(Integer.MAX_VALUE - 1) in TestUnifiedHighlighter.java:566, :631, TestUnifiedHighlighterRanking.java:119, :128, TestUnifiedHighlighterTermIntervals.java:413, :471)
  • a Lucene main checkout (645d4fe14a20) lucene/highlighter/src/java/org/apache/lucene/search/highlight/Highlighter.java:138, :182-186 and lucene/core/src/java/org/apache/lucene/util/PriorityQueue.java:121-124 (the classic Highlighter's rejection)
  • a Lucene main checkout (645d4fe14a20) lucene/highlighter/src/java/org/apache/lucene/search/uhighlight/UnifiedHighlighter.java:100 (DEFAULT_MAX_LENGTH = 10000), :1410, :1447, :1450-1464 (stored values truncated to maxLength when loaded)
Diagrams
  • a Lucene main checkout (645d4fe14a20) lucene/highlighter/src/java/org/apache/lucene/search/uhighlight/FieldHighlighter.java:137-143

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

Repro files

About the diagrams

Made in parallel with this doc from the code at 645d4fe14a20 and an executed probe on the stock main jars (assertions on and off) and with the fix.

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.