All findings · Lucene finding · LFV-265 · uh-zero-maxpassages-npe

UnifiedHighlighter with maxPassages = 0 throws a NullPointerException when the field has a match to highlight

Ask the UnifiedHighlighter for 0 passages and every document with a match to highlight throws a NullPointerException; one without a match gets null. The passage queue counts as full when its size equals maxPassages, so for 0 the empty queue is "full" and its missing weakest entry is read.

Summary

Priority
Low, rank 647UnifiedHighlighter throws an NPE instead of a clean error only for an explicit maxPassages of 0, so this rare-input crash ends the crash group.
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
code reading of the passage queue, confirmed with a public-API test

AI-assisted page: this finding was first signalled by code reading or testing and then checked with AI tools; the checks it went through, and what each found, are under Prior reports, history and checks. Not yet triaged by a human. Snapshot of 2026-11-06.

The finding in 30 seconds

The highlighter keeps a field's best passages in a java.util.PriorityQueue. Once the queue holds maxPassages entries, each new passage is compared with the weakest one, peek(). For 0 that test is already true while the queue is empty, and peek() is null.

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 passages a snippet may use. highlight, highlightFields (one count per field) and highlightWithoutSearcher take it as a plain int with no documented range.
Summary card: UnifiedHighlighter throws a NullPointerException for maxPassages = 0 when there is a match to highlight. highlight("body", query, topDocs, 0) on one document body = "alpha beta" with query body:alpha: expected a null snippet or a clear IllegalArgumentException, actual NullPointerException at FieldHighlighter.java:212, with assertions on and off. Executed on main: maxPassages = 1 gives <b>alpha</b> beta; 0 without a match (body:gamma) gives null; with {title: 0, body: 1} the whole call throws, so body loses its snippet. Why: a new passage is compared with the weakest queued one, peek(), once the queue is full; with 0 the test size() == maxPassages is 0 == 0 on an empty queue, so peek() is null. Fix sketch: if (maxPassages <= 0 || off.nextPosition() == false) return new Passage[0]; with it the snippets are null, body keeps its snippet and the minimal repro passes.
The minimal repro on main: the call with maxPassages = 0, the empty passage queue that already counts as full at line 212, expected vs actual for each call, and the one-line fix. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. Index one document, body = "alpha beta", search body:alpha and call highlight("body", query, topDocs, 1): you get <b>alpha</b> beta.
  2. Call it with maxPassages = 0. You expect null: that is what 0 already returns for a document without a match (query body:gamma). A clear IllegalArgumentException would also be fine.
  3. You get a NullPointerException ("the return value of PriorityQueue.peek() is null") from FieldHighlighter.java:212, with assertions on and off.
  4. Why: the best passages sit in a queue that counts as full when size() == maxPassages. With 0 the empty queue is already full (0 == 0), so the new passage is compared with the weakest entry, peek(), which is null.
  5. With two fields, title = "alpha" and body = "alpha beta", and per-field counts {0, 1}, the whole highlightFields call throws: body loses the <b>alpha</b> beta it gets with {1, 1}.

Who is affected

Code that passes maxPassages = 0 for a field, typically by forwarding a client's snippet count unchecked. Every document with a match to highlight fails, and a multi-field call loses all its snippets. Default settings; all three offset sources tested. It fails loudly: no wrong snippet, no index change, no residue.

Mechanism, terms and numbers580 words

The UnifiedHighlighter keeps a field's best maxPassages passages in a java.util.PriorityQueue. When a passage is finished, FieldHighlighter.maybeAddPassage treats the queue as full when passageQueue.size() == maxPassages and then compares the passage with the lowest-scoring entry, passageQueue.peek() (FieldHighlighter.java:212 on main 645d4fe14a20). For maxPassages = 0 the still empty queue counts as full (0 == 0), peek() returns null, and .getScore() throws.

That happens for every document with a match inside the highlighted text of the field (a match past the first maxLength characters, 10,000 by default, counts as no match), through highlight, highlightFields and highlightWithoutSearcher, with offsets from postings, term vectors or re-analysis, with assertions on and off. A document without a match returns before the queue is built and gets null, so 0 works or fails depending only on whether the document matches. With per-field counts {0, 1}, when the 0 field has such a match, the whole call throws and the other field loses its snippet. Nothing checks the values. The check dates from the first UnifiedHighlighter commit (LUCENE-7438, 2016), so every release from 6.3.0 to 10.6.0 and main is affected.

How the code gets there (main 645d4fe14a20)

  1. No value check. highlight(field, query, topDocs, maxPassages) wraps the value in an array and calls highlightFields (UnifiedHighlighter.java:766-770). highlightFieldsAsObjects checks the field list and the array lengths (:892-897) but never the values, and builds one FieldHighlighter per field with its own count (:922-924). highlightWithoutSearcher does the same for one string (:1096-1107).
  2. No match: no queue, null. highlightOffsetsEnums returns new Passage[0] before the queue exists when the field has no term occurrence (FieldHighlighter.java:137-139). The fallback summary then asks for maxPassages sentences when maxNoHighlightPassages is at its default -1 (:89-94), that is 0, and the result is null (:96-100). A match beyond the highlighted text counts as no match: with a searcher the stored text is cut to maxLength characters (10,000 by default, UnifiedHighlighter.java:100, :1443-1447), and an offset at or past that length ends the loop before any passage is scored (FieldHighlighter.java:164-173), so such a document also gets null (executed: a 12,005-character document whose only match starts after character 10,000).
  3. Match: an empty queue that counts as full. The queue gets capacity Math.min(64, maxPassages + 1), 1 for 0, so creating it works (:141-143). When the first scored passage is finished, maybeAddPassage evaluates passageQueue.size() == maxPassages && passage.getScore() < passageQueue.peek().getScore() (:212): 0 == 0 is true, peek() of the empty queue is null, and .getScore() throws.
  4. Why only 0. For every maxPassages >= 1 the queue is non-empty whenever size() == maxPassages. Negative values never get there: a capacity below 1 is rejected by the JDK constructor first (:143), with an IllegalArgumentException. Nor does Integer.MAX_VALUE: its maxPassages + 1 overflows at :143 first, a separate bug.

What each value does on main (executed)

calltodayexpected
maxPassages = 1, document with a match<b>alpha</b> betasame
maxPassages = 0, document without a matchnullsame
maxPassages = 0, document with a match (postings, term vectors or re-analysis; with or without a searcher)NullPointerExceptionnull, or a clear IllegalArgumentException
highlightFields with {0, 1}NullPointerException; the second field's snippet is lostnull and <b>alpha</b> beta, or a clear rejection
maxPassages = -1IllegalArgumentException from the JDKa clear IllegalArgumentException (not claimed)
Exposure and likelihood198 words
  • What goes wrong. The call throws on the first document with a match to highlight, so the request gets no snippets; in a multi-field call every field loses its snippets. The error is an internal NullPointerException, not a clear argument error (the javadoc documents only an IllegalArgumentException for fields without offsets), so a server is likely to report it as its own failure rather than as a bad request.
  • Easy to miss in tests. With 0, a document without a match returns null, so tests on non-matching data pass.
  • Scale. None needed: one document, one term; deterministic.
  • Downstream (code reading only, not run). Solr's unified highlighter copies the per-field hl.snippets into maxPassages without a range check, so hl.method=unified&hl.snippets=0, or f.<field>.hl.snippets=0 next to another field, would reach this code with 0 (no Solr request was run). Elasticsearch and OpenSearch map 0 fragments to Integer.MAX_VALUE - 1 and do not reach it.
  • Workaround. Leave a field that should get no snippet out of the request instead of passing 0.
  • How likely: rare. It needs an explicit 0; Lucene's own tests pass only 1, 2, 3 or 10 (never 0), and no report was found in ten years.

Minimal repro

One JUnit method on the public API: index one document alpha beta, search body:alpha, highlight with maxPassages = 0. It accepts null or an up-front IllegalArgumentException and fails on the NullPointerException. Deterministic, single thread, assertions on or off.

TestZeroMaxPassages.java (lucene/highlighter, package org.apache.lucene.search.uhighlight, 1 test)60 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.document.Document;
import org.apache.lucene.document.Field;
import org.apache.lucene.document.TextField;
import org.apache.lucene.index.DirectoryReader;
import org.apache.lucene.index.IndexWriter;
import org.apache.lucene.index.IndexWriterConfig;
import org.apache.lucene.index.Term;
import org.apache.lucene.search.IndexSearcher;
import org.apache.lucene.search.Query;
import org.apache.lucene.search.TermQuery;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;

public class TestZeroMaxPassages extends LuceneTestCase {

  public void testZeroMaxPassagesWithMatch() throws Exception {
    StandardAnalyzer analyzer = new StandardAnalyzer();
    try (Directory dir = new ByteBuffersDirectory()) {
      try (IndexWriter w = new IndexWriter(dir, new IndexWriterConfig(analyzer))) {
        Document doc = new Document();
        doc.add(new TextField("body", "alpha beta", Field.Store.YES));
        w.addDocument(doc);
      }
      try (DirectoryReader r = DirectoryReader.open(dir)) {
        IndexSearcher searcher = new IndexSearcher(r);
        Query query = new TermQuery(new Term("body", "alpha"));
        UnifiedHighlighter highlighter = UnifiedHighlighter.builder(searcher, analyzer).build();
        String[] snippets;
        try {
          snippets = highlighter.highlight("body", query, searcher.search(query, 1), 0);
        } catch (IllegalArgumentException e) {
          return; // rejecting maxPassages 0 up front is fine
        } catch (NullPointerException e) {
          throw new AssertionError("maxPassages 0: expected a null snippet, got " + e, e);
        }
        assertNull(snippets[0]);
      }
    }
  }
}
Command
cp $REPRO/TestZeroMaxPassages.java $LUCENE/lucene/highlighter/src/test/org/apache/lucene/search/uhighlight/
cd $LUCENE && ./gradlew :lucene:highlighter:test --tests org.apache.lucene.search.uhighlight.TestZeroMaxPassages -Ptests.seed=DEADBEEF
Expected
[null], what 0 already returns for a document without a match (or an up-front IllegalArgumentException).
Observed
NullPointerException: the return value of PriorityQueue.peek() is null, at FieldHighlighter.java:212.

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

More files, environment, full output260 words

The test sits in the uhighlight test package like the upstream tests and uses a fixed StandardAnalyzer and an in-memory ByteBuffersDirectory; by code reading the failing logic does not depend on the codec, similarity or locale the test framework randomizes, and every recorded seed fails the same way. It turns the NullPointerException into an AssertionError that states what was expected (the cause keeps the FieldHighlighter frames). It passes unchanged with either fix, so it is also the regression test.

It needs UnifiedHighlighter.builder and the lucene.tests packages, so it compiles on 9.1.0 and later. Older releases (6.3.0 to 9.0.0) were checked with a separate history probe that uses the public constructor. The test's line numbers in the output include the 16-line license header.

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 Java logic, nothing platform-specific)
Command (environment)
cp $REPRO/TestZeroMaxPassages.java $LUCENE/lucene/highlighter/src/test/org/apache/lucene/search/uhighlight/
cd $LUCENE && ./gradlew :lucene:highlighter:test --tests org.apache.lucene.search.uhighlight.TestZeroMaxPassages -Ptests.seed=DEADBEEF

Observed output

TestZeroMaxPassages > testZeroMaxPassagesWithMatch FAILED
    java.lang.AssertionError: maxPassages 0: expected a null snippet, got java.lang.NullPointerException: Cannot invoke "org.apache.lucene.search.uhighlight.Passage.getScore()" because the return value of "java.util.PriorityQueue.peek()" is null
        at __randomizedtesting.SeedInfo.seed([DEADBEEF:AEC4630373516A5C]:0)
        at org.apache.lucene.search.uhighlight.TestZeroMaxPassages.testZeroMaxPassagesWithMatch(TestZeroMaxPassages.java:54)

        Caused by:
        java.lang.NullPointerException: Cannot invoke "org.apache.lucene.search.uhighlight.Passage.getScore()" because the return value of "java.util.PriorityQueue.peek()" is null
            at org.apache.lucene.search.uhighlight.FieldHighlighter.maybeAddPassage(FieldHighlighter.java:212)
            at org.apache.lucene.search.uhighlight.FieldHighlighter.highlightOffsetsEnums(FieldHighlighter.java:194)
            at org.apache.lucene.search.uhighlight.FieldHighlighter.highlightFieldForDoc(FieldHighlighter.java:86)
            at org.apache.lucene.search.uhighlight.UnifiedHighlighter.highlightFieldsAsObjects(UnifiedHighlighter.java:988)
            at org.apache.lucene.search.uhighlight.UnifiedHighlighter.highlightFields(UnifiedHighlighter.java:858)
            at org.apache.lucene.search.uhighlight.UnifiedHighlighter.highlightFields(UnifiedHighlighter.java:836)
            at org.apache.lucene.search.uhighlight.UnifiedHighlighter.highlight(UnifiedHighlighter.java:769)
            at org.apache.lucene.search.uhighlight.TestZeroMaxPassages.testZeroMaxPassagesWithMatch(TestZeroMaxPassages.java:50)

:lucene:highlighter:test (FAILURE): 1 test, 1  failure

# the same failure, 1 of 1, with the standalone runner (seed DEADBEEF): main 645d4fe14a20 with -ea and -da,
# and the 10.6.0, 10.5.2 and 9.12.3 release jars

Why it looks unintended

  • The javadoc gives maxPassages no range, and its only documented IllegalArgumentException is for fields without offsets.
  • 0 already has a meaning: a document without a match gets "the first maxPassages sentences", null for 0. Only documents with a match crash.
  • The module's other highlighters accept 0: the classic Highlighter turns it into 1; the FastVectorHighlighter and matchhighlight return nothing.
The full argument285 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; the values are never checked (:892-897).
  • 0 has a documented meaning nearby. The no-match summary is "the first maxPassages sentences" (:759-761), null for 0 (executed), and getMaxNoHighlightPassages says "If this is 0 then the resulting highlight is null (not formatted)" (:634-638).
  • 0 is the only int that ends in a NullPointerException. 1 to Integer.MAX_VALUE - 1 work (Integer.MAX_VALUE fails earlier, with a message-less IllegalArgumentException at FieldHighlighter.java:143, a separate overflow); -1 already fails with an IllegalArgumentException (executed).
  • Sibling highlighters. The classic Highlighter coerces the count (Math.max(1, maxNumFragments); // sanity check, Highlighter.java:138); the FastVectorHighlighter rejects only negative counts (BaseFragmentsBuilder.java:145-148) and returns no fragments for 0 (:161); the matchhighlight PassageSelector cuts its result to maxPassages (PassageSelector.java:213) and returns no passages for 0 (executed).
  • Tests. The uhighlight tests pass 1, 2, 3 or 10, never 0 (3 from TestUnifiedHighlighterRanking: maxTopN = 3 at :60, highlight(..., n + 1) at :137); the only 0 is the separate withMaxNoHighlightPassages(0) (TestUnifiedHighlighter.java:843). With a check that rejects values below 1, all existing highlighter tests still pass (executed), so none relies on 0.
  • History. No commit ever validated maxPassages. The predecessor PostingsHighlighter had the same unguarded check, but its new PriorityQueue<>(n, ...) threw the JDK's message-less IllegalArgumentException for 0 first (executed on 6.2.1, 6.3.0 and 6.6.6): an accident, not a check. The UnifiedHighlighter's Math.min(64, maxPassages + 1) removed the accident and exposed the old check.
The devil's-advocate check102 words

The strongest case for "works as designed": UnifiedHighlighter is @lucene.experimental and FieldHighlighter @lucene.internal; "top-N" suggests N > 0; a caller who wants no snippet can leave the field out; no upstream test uses 0; and other Lucene top-N APIs reject 0 ("numHits must be > 0", TopScoreDocCollectorManager.java:64).

Why it fails: that supports at most an explicit IllegalArgumentException with a message (fix B), not a NullPointerException from an empty internal queue that appears only when the document matches. @lucene.experimental allows API changes, not an undocumented crash on an accepted argument, and the module's other highlighters treat 0 as a valid count.

Suggested fix

Recommended Treat 0 as "no highlighted passages" (one line in highlightOffsetsEnums)

Return no passages for maxPassages <= 0 before the queue is built, the early return a document without a match already takes (one line at FieldHighlighter.java:137). Then 0 gives null, and {0, 1} keeps the second field's snippet. The minimal test and all 723 highlighter tests pass.

-    if (off.nextPosition() == false) {
+    if (maxPassages <= 0 || off.nextPosition() == false) {
       return new Passage[0];
     }
All options and trade-offs2 options · 489 words

Two options were validated with gradle in a clean worktree at 645d4fe14a20: the minimal test and ./gradlew :lucene:highlighter:test (723 tests: the module's 722 plus the new one). They differ in what 0 means. Maintainers may prefer either; both remove the NullPointerException.

Treat 0 as "no highlighted passages" (one line in highlightOffsetsEnums) (validated by the checks) (recommended)

Return new Passage[0] for maxPassages <= 0, before the queue is created (FieldHighlighter.java:137). A matching document asked for 0 passages then takes the same path as a non-matching one: null with default settings; with {0, 1} the first field is null and the second keeps <b>alpha</b> beta. Every value of 1 or more is unchanged.

-    if (off.nextPosition() == false) {
+    if (maxPassages <= 0 || off.nextPosition() == false) {
       return new Passage[0];
     }

Trade-offs

  • With withMaxNoHighlightPassages(1), a matching text asked for 0 passages gets the unhighlighted one-sentence summary (alpha beta. gamma alpha.), as a non-matching text already does (executed). If maintainers want null there, the check belongs in highlightFieldForDoc and must keep the summary for non-matching documents.
  • Negative values: with <= 0, -1 on a matching document now fails with "Illegal Capacity: -1" from the summary path (as for non-matching documents today) instead of a message-less exception. Writing maxPassages == 0 leaves negative values exactly as today (executed: the minimal test and the five-test suite pass).

What the checks validated

  • Gradle, clean worktree at 645d4fe14a20, seed DEADBEEF: the minimal test fails before the change (1 test, 1 failure) and passes after it; :lucene:highlighter:test passes 723 tests, 0 failures.
  • On the stock main jars with the class replaced: the minimal test passes with assertions on and off, and an independent five-test suite (each offset source, without a searcher, {title: 0, body: 1}) passes 5 of 5.
  • Earlier, a 476-test highlighter run changed only this repro's result.

Reject maxPassages < 1 up front with a clear message (validated by the checks)

Throw IllegalArgumentException("maxPassages must be >= 1, got: " + maxPassages) in the FieldHighlighter constructor (the class has only one, so every UnifiedHighlighter entry point, UnifiedHighlighter.java:1152, and any subclass's FieldHighlighter goes through it), or next to the existing argument checks at :892-897.

    if (maxPassages < 1) {
      throw new IllegalArgumentException("maxPassages must be >= 1, got: " + maxPassages);
    }

Trade-offs

Turns a call that works today into an error: 0 on a document without a match returns null now and would throw. Servers that forward a client's count (Solr's hl.snippets=0, by code reading) would answer with an error instead of an empty highlight. Rejecting only negative values and applying the guard for 0 is a middle way (not run).

What the checks validated

Gradle, same setup: the minimal test passes (it accepts the up-front rejection); :lucene:highlighter:test passes 723 tests, 0 failures. 0 and -1 then give "maxPassages must be >= 1" for matching and non-matching documents alike (executed).

Downstream note

By code reading only (not run), Solr's unified highlighter passes hl.snippets to maxPassages unchecked, so hl.snippets=0 would reach this code with 0 (no Solr request was run); Elasticsearch and OpenSearch map 0 fragments to Integer.MAX_VALUE - 1 and do not reach it.

How it was found

Found by reading the UnifiedHighlighter's passage code during a review of the highlighter's offset sources, then confirmed with a public-API test. The review's random test driver only tried 1 to 3 passages, so no tool produced the first signal.

The method in full99 words
  • Method: code reading (not a formal method). While modelling how FieldHighlighter forms passages for each offset source, an AI tool noticed that maybeAddPassage treats the queue as full at size() == maxPassages and dereferences peek() without a null check, and asked what happens for 0.
  • It wrote a public-API JUnit test, which failed on main with assertions on and off and on 10.5.2 and 9.12.3, and passed with the one-line guard.
  • The review's random differential driver drew maxPassages from 1 to 3 only, and the upstream tests pass 1, 2, 3 or 10, which is why neither caught it.

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, Solr's JIRA, the mailing lists, downstream projects or the web. Closest, not this bug: #8895 (the shape of the maxPassages API) and #11113 (a different empty-queue NPE).

Searches5 searches

No issue, PR, commit, JIRA entry, list thread, downstream issue or web page reports a NullPointerException (or any error) for maxPassages = 0, or proposes validating maxPassages. 20 hits were read and classified; none is this bug.

  • #8895 / LUCENE-7844 (open): simplifying the maxPassages input API; the body and comments never mention 0.
  • #11113 / LUCENE-10075 (open): a different empty-queue NPE, in the intervals path, with a normal maxPassages; neither fix covers the other.
  • #8490 / LUCENE-7438: the introducing change. #5970 / LUCENE-4905: per-field int[] counts. #5921 / LUCENE-4856: the no-match summary.

Gaps: part of the GitHub queries hit a search quota and were re-run another way; list search matches subjects and leading text only; private reports cannot be searched.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs and commits (apache/lucene incl. the migrated JIRA archive, apache/lucene-solr, apache/solr, Elasticsearch, OpenSearch, solr-ocrhighlighting; all of GitHub for exact identifiers)2026-10-26about 120 queries: class and method names, the NPE message, maxPassages 0/zero, snippets 0, hl.snippets, number_of_fragments 0; the changed files of all 413 open apache/lucene PRsno report; closest #8895, #11113, #8490, #5970, #5921
JIRA (LUCENE and SOLR projects)2026-10-2620 queries (12 SOLR, 8 LUCENE); 17 issues readno report; SOLR-10941 is a different NPE
Mailing lists (Lucene dev@, java-user@, issues@; Solr users, dev, issues)2026-10-2678 queries in two toolsno report
Web2026-10-2617 queriesgeneric NPE pages and pre-2016 highlighter questions only
GitHub apache/lucene (re-check)2026-10-30maxPassages, maxPassages 0, UnifiedHighlighter NullPointerException, FieldHighlighter NullPointerException, UnifiedHighlighter NPE, passageQueue; open PRs mentioning FieldHighlighternothing new; FieldHighlighter.java:212 unchanged on upstream main

History

Present since the UnifiedHighlighter was added (LUCENE-7438, 722e8271243 on master 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; not a regression.

Versions tested4 rows

Introduced by LUCENE-7438 "New UnifiedHighlighter" (#8490), committed by dsmiley on 2016-10-04 as 722e8271243 (master) and 4b6794368df (branch_6x). Its first highlightOffsetsEnums already sized the queue Math.min(64, maxPassages + 1) and had the unguarded passageQueue.size() == maxPassages && passage.score < passageQueue.peek().score.

Later commits only renamed or moved the check: LUCENE-7559 (6.4.0, .getScore()), LUCENE-8145 (7.3.0, moved unchanged into maybeAddPassage, which is why git blame on main names that commit), LUCENE-9570 (9.0.0, reformatted the surrounding code; the check line itself is unchanged since LUCENE-8145). The predecessor PostingsHighlighter had the same check but failed earlier for 0, with the JDK's PriorityQueue capacity check. Old releases were run on JDK 25, not the JDK of their time.

Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20 affectedthe minimal test fails with gradle and with the standalone runner, assertions on and off
10.6.0, 10.5.2, 9.12.3 affectedthe minimal test fails (release jars), frame FieldHighlighter.java:212
6.3.0 - 10.5.2 (87 releases) affectedthe history probe fails on every one (assertions off too on 6.3.0, 9.12.3, 10.5.2); the failing frame moves with the line: highlightOffsetsEnums:178 before 7.3.0, maybeAddPassage after
6.2.1 and earlier no APIno UnifiedHighlighter; the PostingsHighlighter threw a message-less IllegalArgumentException for 0 instead (6.2.1, 6.3.0, 6.6.6)

Independent checks

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

Checks and verdicts267 words

Each executing check ran tests against the stock jars; the cross-model reviewers could not run Java, so a separate run re-ran the repro for them.

Each independent check and its verdict
CheckVerdictWhat it found
finder candidateCode reading, then a public-API JUnit test that fails on main (-ea, -da), 10.5.2 and 9.12.3 and passes with the guard.
cross-model review, refute lens (OpenAI GPT-5.6) not real (low), proceduralRead only; the refute review votes not real when it cannot run the repro. It agreed with the root cause, found the inputs in contract and the intended-behaviour argument weak.
Independent reproducer realIts own five tests (each offset source, without a searcher, {title: 0, body: 1}) fail 5 of 5 on main, 10.5.2 and 9.12.3 and pass with the guard; re-run on 645d4fe14a20: same.
Cross-model adversary, intended lens (OpenAI GPT-5.6) real (medium)Intended-behaviour argument moderate; the behaviour matrix: NPE for 0 with a match, null without, an IllegalArgumentException for -1, null for 0 everywhere with the guard.
Devil's advocate real (low severity)Built the works-as-designed case and rejected it; its claim tests fail on main, 10.5.2 and 9.12.3 and pass with the guard.
adjudication confirmed (high)Re-ran the independent reproduction's and the finder's tests; class wrong error; not security-sensitive.
history sweep and security probe since 6.3.087 releases and main; 1,000 failing calls leave no residue.
this doc reproduced; both fixes validatedGradle on a clean 645d4fe14a20 (fails), each fix with the module's 723 tests (pass), the minimal test on the 9.12.3, 10.5.2 and 10.6.0 jars (fails).

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

Finding record

LFV-265, snapshot of 2026-11-06

Registry title
UnifiedHighlighter with maxPassages=0 throws NullPointerException for every document that has a match (a per-field 0 makes the whole multi-field call fail)
Area
highlighter / UnifiedHighlighter passage selection (FieldHighlighter.maybeAddPassage; maxPassages is never range-checked by UnifiedHighlighter.highlightFieldsAsObjects)
Priority
Low, rank 647: UnifiedHighlighter throws an NPE instead of a clean error only for an explicit maxPassages of 0, so this rare-input crash ends the crash group.
Impact class
wrong error
Impact note
Applications on any release from 6.3.0 through 10.6.0 or on main that call UnifiedHighlighter (highlight, highlightFields, highlightWithoutSearcher; the POSTINGS, TERM_VECTORS and ANALYSIS offset sources were run; default settings) with maxPassages 0 for a field get an internal NullPointerException instead of a null snippet or a clear IllegalArgumentException, for every document that matches in that field, and with per-field counts such as {1, 0} the whole call fails, so the other fields lose their snippets too; there are no wrong results, no corruption and no hang, no index size is needed, and by code reading (not executed) Solr's hl.snippets=0 with hl.method=unified reaches it, while Elasticsearch and OpenSearch map 0 to Integer.MAX_VALUE - 1 and do not.
Summary
You call UnifiedHighlighter.highlight(field, query, topDocs, 0), or highlightFields with a per-field count of 0, and expect what the same call already does for a document without a match: a null snippet (or, if 0 is not allowed, an IllegalArgumentException that says so). Instead, for every document that has a match in the field, FieldHighlighter.maybeAddPassage tests passageQueue.size() == maxPassages (0 == 0) while the passage queue is still empty and then calls passageQueue.peek().getScore(), so a NullPointerException escapes the call. With {title: 0, body: 1} the body field, which 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) [read: git log -G, git blame and git tag --contains 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 version of FieldHighlighter.highlightOffsetsEnums already sized the queue Math.min(64, maxPassages + 1) and had the unguarded check 'passageQueue.size() == maxPassages && passage.score < passageQueue.peek().score'. Later commits only renamed or moved it: LUCENE-7559 (c51e89014ac / 23b8bb66935, 6.4.0) switched to getScore(), LUCENE-8145 (9422609a5d4 / aa5c3a5c222, 7.3.0) moved the check unchanged into maybeAddPassage (git blame on main names this commit for line 212), and LUCENE-9570 (6faa4f98e04) reformatted it. The predecessor PostingsHighlighter had the same unguarded check, but its new PriorityQueue<>(n, ...) threw the JDK's message-less IllegalArgumentException for n = 0 first [exec on 6.2.1, 6.3.0 and 6.6.6], so the NullPointerException is new with the UnifiedHighlighter's queue sizing; 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, 21 in 9.x, 12 in 10.x), 10.6.0 [exec: the minimal test fails on its release jars] and main ea57360912ed [exec: the history probe, a public-API main(), fails on every one with -ea, and with -da on 6.3.0, 9.12.3, 10.5.2 and main; the finder's JUnit fails on 9.1.0, 9.12.3, 10.0.0, 10.5.2 and main; the independent reproduction's and the intended-behaviour check's tests fail on 9.12.3, 10.5.2 and main]; not affected: releases up to 6.2.1, which have no UnifiedHighlighter; not fixed on main (FieldHighlighter.java:212 unchanged, and no open PR touches FieldHighlighter.java or the handling of maxPassages); release branches after 10.5.2 were not checked apart from the 10.6.0 release jars
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
non-formal: code reading
Upstream and prior reports
none found (2026-10-26, the prior-report record: 281 recorded queries, namely the scripted prior-report search (30 GitHub issue and PR queries planned, 13 answered before GitHub's search rate limit ran out; 32 queries over the dev@, java-user@, issues@ and commits@ lists; 8 JIRA-archive and 8 web queries), 107 read-only GitHub GraphQL searches over apache/lucene, apache/lucene-solr, apache/solr, elastic/elasticsearch, opensearch-project/OpenSearch, dbmdz/solr-ocrhighlighting and all of GitHub for exact identifiers, 20 JIRA queries (12 SOLR, 8 LUCENE), 46 lists.apache.org queries over the Lucene and Solr lists, 9 web searches and local git-history searches; the changed files of all 413 open apache/lucene PRs; 20 hits read and classified, none the same bug; the finder, the independent reproduction, the cross-model review and the intended-behaviour check found nothing either); not fixed upstream (FieldHighlighter.java:212 unchanged on main ea57360912ed, the newest commit touching FieldHighlighter.java is dbf0d1426f1 (#16056, a toArray modernization), and no open PR touches FieldHighlighter.java or maxPassages handling); closest items, not reports: #8895 / LUCENE-7844 (open since 2017; the shape of the maxPassages API, nothing about 0), #8490 / LUCENE-7438 (the introducing change), #11113 / LUCENE-10075 (open; a different empty-queue NullPointerException in the WEIGHT_MATCHES intervals path with a normal maxPassages), #5970 / LUCENE-4905 (the per-field int[] API) and #5921 / LUCENE-4856 (the no-match summary path)
Lucene commit tested
main 645d4fe14a20 (gradle in a clean scratch worktree, and the standalone runner with assertions on and off); the 9.12.3, 10.5.2 and 10.6.0 release jars; both fixes with the highlighter module's tests (2026-10-30). The history probe ran all 87 releases from 6.3.0 to 10.5.2 and main ea57360912ed (2026-10-26).

Lucene and upstream sources

Finding record
  • lucene/highlighter/src/java/org/apache/lucene/search/uhighlight/FieldHighlighter.java:83-94 (at ea57360912ed) (highlightFieldForDoc: try-with-resources OffsetsEnum, :86 highlightOffsetsEnums, :89-94 no passages -> getSummaryPassagesNoHighlight(maxNoHighlightPassages == -1 ? maxPassages : maxNoHighlightPassages)), :136-138 (no term occurrence -> new Passage[0] before the queue exists), :140-143 (new PriorityQueue<>(Math.min(64, maxPassages + 1), ...)), :194 (maybeAddPassage call), :205-218 (maybeAddPassage: :212 'passageQueue.size() == maxPassages && passage.getScore() < passageQueue.peek().getScore()')
  • lucene/highlighter/src/java/org/apache/lucene/search/uhighlight/UnifiedHighlighter.java:94 (at ea57360912ed) (@lucene.experimental), :633-638 (getMaxNoHighlightPassages javadoc: 'If this is 0 then the resulting highlight is null'), :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, only an IllegalArgumentException for fields without offsets), :891-897 (highlightFieldsAsObjects checks 'fieldsIn must not be empty' and 'invalid number of maxPassagesIn', never the values)
The finding in 30 seconds
  • a Lucene main checkout (645d4fe14a20) lucene/highlighter/src/java/org/apache/lucene/search/uhighlight/FieldHighlighter.java:83-100, :109-127, :137-143, :164-173, :194, :201-224 (:212 the check)
  • a Lucene main checkout (645d4fe14a20) lucene/highlighter/src/java/org/apache/lucene/search/uhighlight/UnifiedHighlighter.java:100 (DEFAULT_MAX_LENGTH), :116, :282 (maxNoHighlightPassages default -1), :766-770, :858, :892-897, :922-924, :988, :1096-1107, :1443-1447 (stored text cut to maxLength)
Why it looks unintended
  • a Lucene main checkout (645d4fe14a20) lucene/highlighter/src/java/org/apache/lucene/search/uhighlight/UnifiedHighlighter.java:94 (@lucene.experimental), :634-638, :757-764, :819-820, :845-846, :881-882, :892-897, :1089-1090
  • a Lucene main checkout (645d4fe14a20) lucene/highlighter/src/java/org/apache/lucene/search/uhighlight/FieldHighlighter.java:32 (@lucene.internal)
  • a Lucene main checkout (645d4fe14a20) lucene/highlighter/src/java/org/apache/lucene/search/highlight/Highlighter.java:138; a Lucene main checkout (645d4fe14a20) lucene/highlighter/src/java/org/apache/lucene/search/vectorhighlight/BaseFragmentsBuilder.java:145-148, :161; a Lucene main checkout (645d4fe14a20) lucene/highlighter/src/java/org/apache/lucene/search/matchhighlight/PassageSelector.java:93, :213
  • a Lucene main checkout (645d4fe14a20) lucene/highlighter/src/test/org/apache/lucene/search/uhighlight/TestUnifiedHighlighter.java:843; TestUnifiedHighlighterTermIntervals.java:673; TestUnifiedHighlighterRanking.java:60, :111, :136-137 (maxPassages 1, 2 and 3)
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/search/TopScoreDocCollectorManager.java:64

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 and with the one-line guard.

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.