All findings · Lucene finding · LFV-372 · sorter-heapchild-int-overflow

IntroSorter's heap-sort fallback overflows int and asks for a negative slot when 2*to - from > 2^31

When IntroSorter (ArrayUtil.introSort) falls back to heap sort on a slice with 2*to - from > 2^31 (over 2^30 elements, or one ending near the maximum array length), its int child index wraps negative and selects a slot outside the slice: ArrayIndexOutOfBoundsException. It also needs an order that defeats quicksort.

Summary

Priority
Low, rank 1064An internal sorter's heap-sort fallback throws only on slices of more than 2^30 elements with adversarial ordering, a practically unreachable loud overflow.
Impact
crash
Since
4.4.0 (LUCENE-4946, commit b895ebde4340, 2013-05-03)introduced 2013-05-03 · over 13 years
Affected
4.4.0-10.6.0 and main
Status
documented
Found by
formal: Z3 check of the sorters' int index arithmetic

AI-assisted page: this finding was first signalled by a formal tool's own output 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

Background: how IntroSorter falls back to heap sort

  1. IntroSorter is Lucene's general-purpose in-place sort, behind ArrayUtil.introSort and CollectionUtil.introSort. It sorts the slots from (inclusive) to to (exclusive) through compare, setPivot/comparePivot and swap callbacks.
  2. It runs quicksort, but some orders make quicksort pick bad pivots again and again. After 2 x log2(n) levels it stops and heap-sorts the part that is left, which is always O(n log n).
  3. Heap sort views the slots from start on as a binary tree: the node at offset k has its children at offsets 2k + 1 and 2k + 2. A Java int holds at most 2,147,483,647.
Diagram of the bug: IntroSorter heap-sorts a slice that ends near the int maximum. For a leaf node, heapChild computes the child index start + 2k + 1 in int; the exact value is above 2,147,483,647, so it wraps to a negative slot, the check child < to passes, and compare is called with a slot outside [from, to), which throws ArrayIndexOutOfBoundsException on an array. The fix loops only while a left child exists, i - from < (to - from) >>> 1.
Heap sort near the top of the int range: a leaf's child index 2k + 1 passes 2,147,483,647, wraps to a negative number and slips past the child < to check, so the sorter is asked for a slot outside the slice. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. You sort the last 60 slots of a maximum-length array (ArrayUtil.MAX_ARRAY_LENGTH = 2,147,483,631), slots 2,147,483,571 to 2,147,483,630, with ArrayUtil.introSort or your own IntroSorter, and the values come in an order that defeats quicksort's pivot choice.
  2. You expect a sorted slice, with only those 60 slots read or swapped.
  3. After 2 x log2(60) = 10 quicksort levels, IntroSorter heap-sorts the last 27 slots, from 2,147,483,604. Heap sort puts a node's child at start + 2k + 1, in int: for the last slot (k = 26) that is 2,147,483,657, past the int maximum 2,147,483,647, so it wraps to -2,147,483,639.
  4. The check child < to accepts the negative number, so the sorter is asked for slot -2,147,483,639. On a real array of that length ArrayUtil.introSort throws ArrayIndexOutOfBoundsException: Index -2147482757 out of bounds for length 2147483631 (with a 1,000-value adversarial tail).
  5. Any slice with 2*to - from > 2^31 can hit it, for example more than 2^30 (1,073,741,824) elements from index 0, but only in an order that uses up the quicksort budget: random data in the same slice did not reach heap sort in the recorded runs.

Who is affected

Code that calls ArrayUtil.introSort, CollectionUtil.introSort or its own IntroSorter on a slice of more than 2^30 elements, or one ending near the maximum array length, in an order that defeats quicksort. No setting is involved and assertions make no difference. No in-tree Lucene path was shown to reach it. It fails loudly, never as a silent mis-sort.

Mechanism, terms and numbers666 words

IntroSorter (behind ArrayUtil.introSort and CollectionUtil.introSort) runs quicksort and falls back to Sorter.heapSort(from, to) once its depth budget of 2 * log2(n) levels is used up. Heap sort's siftDown finds a node's left child with heapChild(from, i) = ((i - from) << 1) + 1 + from, computed in int, and loops while leftChild < to. For a node with no child the exact index can be 2^31 or more; it then wraps to a negative int, passes leftChild < to, and compare(i, leftChild) runs with that negative slot. For array-valid slots (0 <= from <= to) this can happen exactly when 2*to - from > 2^31: more than 2^30 = 1,073,741,824 elements from index 0, or a slice that ends near ArrayUtil.MAX_ARRAY_LENGTH. It also needs an element order that uses up the quicksort budget; the recorded random controls did not. Array-backed sorts throw ArrayIndexOutOfBoundsException at the first out-of-slice compare, before any out-of-slice swap; a custom Sorter subclass is handed the negative slot. It is never a silent mis-sort through Lucene's array sorters. The code dates from LUCENE-4946 (4.4.0, 2013), and an overflow-safe loop guard fixes it. No in-tree Lucene caller was shown to reach it.

The bug

  1. IntroSorter.sort(from, to) calls sort(from, to, 2 * MathUtil.log(to - from, 2)) (IntroSorter.java:44). When --maxDepth < 0 it calls heapSort(from, to) on the sub-range left at that point (:59-61, "Max recursion depth exceeded: fallback to heap sort").
  2. heapSort (Sorter.java:259-268) builds the heap with heapify (:270-274), then sifts each extracted element down. siftDown loops for (int leftChild = heapChild(from, i); leftChild < to; leftChild = heapChild(from, i)) (:277) and calls compare(i, leftChild) first (:279).
  3. heapChild(from, i) is ((i - from) << 1) + 1 + from in int (:300-302). The largest node, to - 1, gets the exact child 2*to - from - 1, so some node's child index reaches 2^31 exactly when 2*to - from > 2^31 (a Z3 proof). For array-valid slots (0 <= from <= i < to <= Integer.MAX_VALUE, the model's precondition; checkRange rejects only to < from) the exact value is below 2^32, so the wrapped int is always negative, and every negative value passes leftChild < to. Whether siftDown reaches such a leaf depends on the data.
  4. ArrayIntroSorter.compare reads arr[j] (ArrayIntroSorter.java:40-42) and throws ArrayIndexOutOfBoundsException; the default IntroSorter.compare calls setPivot(i) and comparePivot(j) (IntroSorter.java:155-158), so a custom sorter is handed the negative slot. This happens before any out-of-slice swap; earlier in-slice swaps have run, so the slice is left partly reordered.
  5. On this path the other index arithmetic is overflow-safe: heapParent uses >>> 1 (Sorter.java:296-298; Z3 Q2 UNSAT) and IntroSorter's pivot-sampling indices, including the midpoint (from + last) >>> 1 (IntroSorter.java:67), stay in the slice (Q3, Q3b UNSAT). Here only heapChild's << 1 wraps. Not all of the sorters' index arithmetic is safe: the same Z3 sweep found that Sorter.upper2's t += delta << 1 (Sorter.java:173, used only by TimSorter's merge) can wrap (Q4 SAT, not executed), and insertionSort's from + 1 (Sorter.java:241) wraps for the empty range [Integer.MAX_VALUE, Integer.MAX_VALUE). Both are separate sites, not part of this bug.

The numbers

The minimal test sorts 60 values at slots [2147483571, 2147483631), the last 60 slots of an array of length MAX_ARRAY_LENGTH = 2,147,483,631 (16-byte array header). A trace of the same input shows heapSort(2147483604, 2147483631) (27 slots); heapify walks a value down to the last slot, 2,147,483,630 (offset 26, a leaf), and heapChild gives 2,147,483,604 + 53 = 2,147,483,657, which wraps to -2,147,483,639. With a 24-byte header (-XX:-UseCompressedOops) the slice is [2147483563, 2147483623) and the bad slot -2,147,483,647. On a real Integer[2147483631] with a 1,000-value adversarial tail, ArrayUtil.introSort throws Index -2147482757 out of bounds for length 2147483631. Random values in the same slice sort without reaching heap sort.

Exposure and likelihood188 words
  • Who is hit. Application code that sorts with ArrayUtil.introSort (ArrayUtil.java:509-520), CollectionUtil.introSort (CollectionUtil.java:133, by reading) or its own IntroSorter subclass. These classes are @lucene.internal but public, so downstream code can call them.
  • Default vs opt-in. The default code path of the sorter; no setting. -ea and -da behave the same.
  • Scale. Both (a) 2*to - from > 2^31 (more than 2^30 elements from 0, or a slice ending near MAX_ARRAY_LENGTH, about 8 GiB of references) and (b) an order that exhausts the 2*log2(n) budget (about 60 levels for a billion elements). The from-0 case was shown only by calling heapSort directly on virtual data; it would need an adversarial input of about 10^9 elements end to end.
  • Lucene's own size domain. DocValuesFieldUpdates sorts up to Integer.MAX_VALUE buffered entries with an IntroSorter (index/DocValuesFieldUpdates.java:297-337, :376-378), so sizes above 2^30 are in scope for IntroSorter; reaching the fallback there was not executed and is not claimed.
  • Effect. One sort ends with an exception (or a custom sorter sees a negative slot). No hang, no wrong results through Lucene's array sorters, no index data touched.

Minimal repro

One JUnit test on IntroSorter's public sort and protected hooks: 60 fixed values for the last 60 slots of a maximum-length range, kept in a 60-element array, so no big heap is needed. Its slot check fails the test as soon as a slot outside the slice is requested. Deterministic; the test itself takes 0.02 s.

TestIntroSorterHeapSortAtArrayEnd.java (JUnit, one test)59 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.util;

import org.apache.lucene.tests.util.LuceneTestCase;

public class TestIntroSorterHeapSortAtArrayEnd extends LuceneTestCase {

  public void testHeapSortFallbackStaysInSlice() {
    int[] values = {
      18, 55, 11, 58, 6, 15, 32, 0, 10, 21, 48, 7, 39, 16, 1, 53, 49, 23, 52, 17,
      51, 37, 46, 26, 42, 41, 40, 30, 38, 2, 36, 8, 34, 12, 56, 54, 3, 50, 9, 13,
      44, 19, 25, 27, 29, 31, 45, 59, 43, 22, 24, 28, 4, 14, 20, 57, 35, 47, 33, 5
    };
    int to = ArrayUtil.MAX_ARRAY_LENGTH, from = to - values.length;
    new IntroSorter() {
      int pivot;

      int value(int slot) {
        // expected: only slots in [from, to); actual: the heapSort fallback asks for slot < 0
        if (slot < from || slot >= to) {
          throw new AssertionError("slot " + slot + " is outside [" + from + ", " + to + ")");
        }
        return values[slot - from];
      }

      @Override
      protected void setPivot(int i) {
        pivot = value(i);
      }

      @Override
      protected int comparePivot(int j) {
        return Integer.compare(pivot, value(j));
      }

      @Override
      protected void swap(int i, int j) {
        int tmp = value(i);
        values[i - from] = value(j);
        values[j - from] = tmp;
      }
    }.sort(from, to);
  }
}
Command
cp TestIntroSorterHeapSortAtArrayEnd.java $LUCENE/lucene/core/src/test/org/apache/lucene/util/
cd $LUCENE && ./gradlew :lucene:core:test --tests org.apache.lucene.util.TestIntroSorterHeapSortAtArrayEnd -Ptests.seed=DEADBEEF
# assertions off: add -Ptests.asserts=false (same result)
Expected
Every slot passed to setPivot, comparePivot and swap lies in [2147483571, 2147483631).
Observed
AssertionError: slot -2147483639 is outside [2147483571, 2147483631), from Sorter.siftDown in the heap-sort fallback, with assertions on and off.

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

More files, environment, full output360 words

The 60 values are a fixed permutation of 0..59, computed once with McIlroy's quicksort adversary ("A Killer Adversary for Quicksort", 1999) run through IntroSorter at slots [0, 60), so the sort uses up its depth budget; frozen, they replay the same comparisons with a plain int comparator. 60 is the smallest size of such an adversary input (sizes 17 to 300 were searched) that fails with both a 16- and a 24-byte array header; the simple orders tried (ascending, descending, organ pipe, sawtooth and others) produced no slot outside the slice at this position up to n = 4,096. The test asserts only that no slot outside [from, to) is requested, not sortedness. The slice ends at MAX_ARRAY_LENGTH, not Integer.MAX_VALUE, so the arithmetic is exactly that of ArrayUtil.introSort(a, from, to) on a real maximum-length array; the same failure on such a real array needs about 12 GB of heap and is evidence, not the proposed test.

As the regression test: the same test, for example as a new method in TestIntroSorter.

Environment

lucenemain 645d4fe14a20 (11.0.0-SNAPSHOT); also fails on 10.6.0, 10.5.2 and 9.12.3
jdkOpenJDK 25.0.3 (build 25.0.3+9-LTS)
osLinux, aarch64
Command (environment)
cp TestIntroSorterHeapSortAtArrayEnd.java $LUCENE/lucene/core/src/test/org/apache/lucene/util/
cd $LUCENE && ./gradlew :lucene:core:test --tests org.apache.lucene.util.TestIntroSorterHeapSortAtArrayEnd -Ptests.seed=DEADBEEF
# assertions off: add -Ptests.asserts=false (same result)

Observed output

# gradle, main 645d4fe14a20, seed DEADBEEF (assertions on; the same with -Ptests.asserts=false)
TestIntroSorterHeapSortAtArrayEnd > testHeapSortFallbackStaysInSlice FAILED
    java.lang.AssertionError: slot -2147483639 is outside [2147483571, 2147483631)
        at __randomizedtesting.SeedInfo.seed([DEADBEEF:9A0DA62F0AC7FEFD]:0)
        at org.apache.lucene.util.TestIntroSorterHeapSortAtArrayEnd$1.value(TestIntroSorterHeapSortAtArrayEnd.java:36)
        at org.apache.lucene.util.TestIntroSorterHeapSortAtArrayEnd$1.comparePivot(TestIntroSorterHeapSortAtArrayEnd.java:48)
        at org.apache.lucene.util.IntroSorter.compare(IntroSorter.java:157)
        at org.apache.lucene.util.Sorter.siftDown(Sorter.java:279)
        at org.apache.lucene.util.Sorter.heapify(Sorter.java:272)
        at org.apache.lucene.util.Sorter.heapSort(Sorter.java:263)
        at org.apache.lucene.util.IntroSorter.sort(IntroSorter.java:61)
        at org.apache.lucene.util.IntroSorter.sort(IntroSorter.java:44)
        at org.apache.lucene.util.TestIntroSorterHeapSortAtArrayEnd.testHeapSortFallbackStaysInSlice(TestIntroSorterHeapSortAtArrayEnd.java:57)

# scratch trace of the same input (abridged): the heap-sort sub-range and the wrapped child
heapSort(2147483604, 2147483631) size=27
compare(i=2147483630, leftChild=-2147483639)  exact child = 2147483604 + 2*26 + 1 = 2147483657 > 2147483647

# -XX:-UseCompressedOops (24-byte array header, MAX_ARRAY_LENGTH = 2147483623)
java.lang.AssertionError: slot -2147483647 is outside [2147483563, 2147483623)

# ArrayUtil.introSort on a real Integer[2147483631], adversarial last 1,000 values (-Xmx12g, -da; main ea57360912ed and 645d4fe14a20, and 10.5.2)
java.lang.ArrayIndexOutOfBoundsException: Index -2147482757 out of bounds for length 2147483631
        at org.apache.lucene.util.ArrayIntroSorter.compare(ArrayIntroSorter.java:41)
        at org.apache.lucene.util.Sorter.siftDown(Sorter.java:279)
        ...
        at org.apache.lucene.util.ArrayUtil.introSort(ArrayUtil.java:519)    (ArrayUtil.java:520 on 10.5.2)

# with either fix: the test passes (assertions on and off for the loop guard)

Why it looks unintended

  • Sorter.sort and ArrayUtil.introSort promise to sort the slice [from, to), reject only to < from, and this slice is within MAX_ARRAY_LENGTH.
  • The fallback is meant for adversarial orders: it "prevents quicksort from running into its worst-case quadratic runtime".
  • The neighbouring index arithmetic is overflow-safe (>>> 1); the 2013 "no real issue" overflow remark was about the end index, not heapChild.
The full argument232 words
  • Contract. Sorter.sort sorts "the slice which starts at from (inclusive) and ends at to (exclusive)" (Sorter.java:75-79); heapSort sorts "items between from inclusive and to exclusive" and "is used as a fall-back by IntroSorter" (:254-258); checkRange rejects only to < from (:81-86). ArrayUtil.introSort "Sorts the given array slice" with no size limit (ArrayUtil.java:509-520); MAX_ARRAY_LENGTH is the documented maximum (:32-34). No javadoc, CHANGES.txt, MIGRATE.md text or test sets a 2^30 limit.
  • Tests. Upstream tests use small offsets and lengths, so the heap path at large indices is untested, not specified. TestArrayUtil.testQuickToHeapSortFallback (TestArrayUtil.java:168-178) is named for the fallback (its comment dates it to LUCENE-3054 and the old merge-sort fallback), but its input, createSparseRandomArray(40000) (:159-166), holds only 0s and 1s and does not reach heapSort on main: a fact-check probe counted 0 of 6,000 such arrays reaching it.
  • Purpose of the fallback. The IntroSorter javadoc (IntroSorter.java:19-25), and the LUCENE-7097 description (#8152, by Mike McCandless): introsort "detects when an adversary is at work and cuts over to heapsort".
  • Precedent. LUCENE-6293 (#7355), an AIOOBE in TimSorter on a huge, specially patterned input, was fixed although its author (Adrien Grand) called it "rather unlikely to happen in practice". In a 2019 IntroSelector review (lucene-solr PR #966) he wrote that index additions "can overflow on large arrays ... This is a famous bug".
The devil's-advocate check233 words

The strongest case (an independent reviewer from another model family, OpenAI GPT-5.6; the intended-behaviour checker; the adjudication): every class involved is @lucene.internal ("APIs in this package are primarily internal and may change between releases"); the first test called the package-private heapSort; the trigger is extreme in size and adversarial in order; no in-tree caller reaches it; and in the 2013 LUCENE-4946 review (#6010) Uwe Schindler wrote "The overflow issue is no real issue as the meximum array size is limited, too" (sic).

Why it fails: @lucene.internal marks API stability, not a size cap or a correctness waiver; the minimal test uses only the public sort and protected hooks, and ArrayUtil.introSort fails on a real legal array; the 2013 remark answered Dawid Weiss's point that an exclusive end index "would overflow into negative values" when sorting a full array, the to bound at 2^31, while heapChild already wraps from 2^30 elements on and was never discussed; and adversarial orders are what the fallback is for. These limit severity, not validity, and they do limit it: the trigger is remote (an array of about 8 GiB of references with a 12 GiB heap, or about 10^9 elements, in an order built to defeat quicksort), and @lucene.internal supports a low priority. The cross-model reviewer rated the works-as-designed case weak; the fact check's cross-model reviewer rated it moderate: enough for low priority, not for "by design".

Suggested fix

Recommended Loop in siftDown only while a left child exists

In siftDown, loop only while a left child exists, while (i - from < (to - from) >>> 1), and compute heapChild inside the loop: this check cannot overflow and the comparisons stay the same (java.util.PriorityQueue uses this guard). With it the repro passes with assertions on and off, and the core sorter tests pass.

   void siftDown(int i, int from, int to) {
-    for (int leftChild = heapChild(from, i); leftChild < to; leftChild = heapChild(from, i)) {
+    // Loop while i has a left child. Unlike heapChild(from, i) < to, this test cannot overflow.
+    while (i - from < (to - from) >>> 1) {
+      final int leftChild = heapChild(from, i);
       final int rightChild = leftChild + 1;
All options and trade-offs3 options ยท 580 words

Two equivalent fixes; both were run through gradle in a scratch worktree of 645d4fe14a20.

Loop in siftDown only while a left child exists (validated by the checks) (recommended)

With k = i - from and n = to - from, a left child exists iff 2k + 1 < n iff k < floor(n / 2). Inside the loop heapChild is then at most to - 1 and rightChild at most to, so neither can overflow. It is the guard java.util.PriorityQueue uses (int half = n >>> 1; // loop while a non-leaf, while (k < half)).

   void siftDown(int i, int from, int to) {
-    for (int leftChild = heapChild(from, i); leftChild < to; leftChild = heapChild(from, i)) {
+    // Loop while i has a left child. Unlike heapChild(from, i) < to, this test cannot overflow.
+    while (i - from < (to - from) >>> 1) {
+      final int leftChild = heapChild(from, i);
       final int rightChild = leftChild + 1;

Trade-offs

No API or format change, and the same comparisons and swaps as today for every slice that does not overflow. The loop test compares i - from with (to - from) >>> 1 (loop-invariant) instead of comparing heapChild(from, i) with to; heapChild is still computed inside the loop, so each step adds one subtraction. Performance was not measured, and this is the cold fallback path.

What the checks validated

  • Gradle, scratch worktree of 645d4fe14a20: without the fix the repro fails with assertions on and off.
  • With the fix it passes with assertions on and off (-Ptests.asserts=false).
  • Closest suites, the core util sorter and selector tests (TestIntroSorter, TestArrayUtil, TestCollectionUtil, TestInPlaceMergeSorter, TestTimSorter, TestMSBRadixSorter, TestStableMSBRadixSorter, TestStringSorter, TestIntroSelector): 9 classes, 74 tests, 0 failures.
  • ./gradlew :lucene:core:check -x test (google-java-format, ecj lint, forbidden APIs) passes with the repro test in place. The full test suite was not run.
  • Earlier, the same guard written as a for loop, shadowed into the stock jars, passed the finder's test, a separate public-API probe and the minimal test with assertions on and off.

Saturate heapChild in long arithmetic (validated by the checks)

Keeps siftDown as is.

   static int heapChild(int from, int i) {
-    return ((i - from) << 1) + 1 + from;
+    // The exact index can exceed Integer.MAX_VALUE; saturate, since any value >= to means no child.
+    return (int) Math.min(((long) (i - from) << 1) + 1 + from, Integer.MAX_VALUE);
   }

Trade-offs

Correct because to <= Integer.MAX_VALUE, so a saturated value always fails leftChild < to. One long shift and a min per step; slightly less direct than the loop guard.

What the checks validated

Gradle in the same scratch worktree: the repro, TestIntroSorter and TestArrayUtil pass with assertions on (3 classes, 29 tests, 0 failures). With assertions off, the minimal test passes with this fix standalone (fact check). The other suites and the static checks were not run for this option. The independent reproducer validated a close variant (the loop computing the child in long) on its 11 tests and on the real Integer[MAX_ARRAY_LENGTH] run.

Document or enforce a 2^30 limit (not recommended)

Reject slices with 2*to - from > 2^31 in checkRange, or document the limit.

Trade-offs

Rejects legal slices that sort fine with ordinary data; Math.multiplyExact would likewise turn the bad slot into an ArithmeticException and still fail a legal sort.

Downstream note

Downstream code that sorts more than 2^30 (about 1.07 billion) elements, or a slice at the top of a maximum-length array, in an order that defeats quicksort, with ArrayUtil.introSort or its own IntroSorter, can hit this exception until it is fixed. A custom Sorter subclass is handed a negative slot; what it then does is up to that subclass.

How it was found

Formal: an SMT solver (Z3) checked the sorters' int index arithmetic with exact Java wrap-around. It found that heapChild can wrap inside siftDown and proved the exact condition, 2*to - from > 2^31, and that to <= 2^30 is safe. A JUnit on the stock build then witnessed it, and independent checks reached it through the public API.

The method in full177 words

Method: formal (SMT solving). In the hunt unit on sorters and selectors, the finder modelled the int index arithmetic of Sorter, IntroSorter and their helpers as 32-bit bit-vectors with exact Java wrap-around and asked Z3: Q1 (can heapChild wrap inside siftDown for a legal slice?) SAT; Q1b, the minimal to for from = 0: 1,073,741,825; Q1c, a 2-slot slice at the top of the range; Q1d, the exact condition 2*to - from > 2^31 (negation UNSAT); Q1e, safe for to <= 2^30 (UNSAT). heapParent and IntroSorter's pivot-sampling indices were proved to stay in range (Q2, Q3, Q3b UNSAT); the same sweep found a possible wrap in TimSorter's Sorter.upper2 (Q4 SAT; not executed; a separate site). The unit was chosen by area (the sorters' index arithmetic), not with this bug in mind. A same-package JUnit that calls heapSort at slots [MAX - 1000, MAX) then failed on the stock build; an independent reproducer built the public-API path and the real-array run with McIlroy's adversary, and the test was later cut to the 60-value one shown here.

Prior reports, history and checks

Prior reports

No prior report found in GitHub issues and PRs (including the migrated JIRA issues), commits, open PRs, the Lucene mailing lists, downstream trackers or the web (2026-10-26); not fixed upstream. The 2013 review of the introducing change discussed a different overflow.

Searches4 searches

Closest items, none a report of this bug: #6010 (LUCENE-4946, the introducing issue; its overflow remark concerns the exclusive end index at 2^31); #7355 (LUCENE-6293, a fixed TimSorter AIOOBE at extreme scale, precedent); #8152 (LUCENE-7097, the depth budget, performance only); #9866 (LUCENE-8822, open, an UnsupportedOperationException from MSBRadixSorter.compare under heapify/siftDown, a different symptom); #11477 (LUCENE-10441, open, an AIOOBE in BytesRefHash.get through binarySort); open PR #12800 (makes binarySort protected; no heap code). Limits: GitHub search does not index code or diffs; private reports are not visible; searches cannot prove that no report exists, and #6010 is related discussion, not an identified duplicate.

What was searched, when, and what turned up
WhereWhenHowFound
apache/lucene issues and PRs (incl. the migrated JIRA issues), commits, downstream repositories2026-10-26a scripted prior-report search (13 issue/PR, 3 commit and 2 downstream queries), 56 direct GitHub searches over apache/lucene, apache/lucene-solr, apache/lucenenet, apache/solr, Elasticsearch, OpenSearch and org:apache, and the verifiers' 74 queriesno report; 23 hits read, none the same bug
Open PRs2026-10-26changed files of all 411 open apache/lucene PRs; diffs of the 5 that touch sorter filesnone changes the heap code
JIRA archive, mailing lists, web2026-10-2610 JIRA-archive queries; 32 queries over dev@, java-user@, issues@ and commits@; 6 web queriesno report
Commit history2026-10-26git log --all -S heapChild in the history clone; upstream main e1c4184cb983 (2026-10-26), rechecked at 2f424fd428da (2026-10-30)the two LUCENE-4946 commits and a 2021 commit that retired the branch_8x tree; heapChild and siftDown unchanged since 2013

History

Present since Sorter and IntroSorter were created by LUCENE-4946 (b895ebde4340, 2013-05-03; first release 4.4.0). Every release from 4.4.0 to 10.6.0 and main is affected; 14 versions were executed. Not a regression.

Versions tested8 rows

Introduced by LUCENE-4946 "Refactor SorterTemplate (now Sorter)" (Adrien Grand; now issue #6010; no GitHub PR): trunk commit b895ebde4340 (svn r1478785) and the branch_4x backport fd331e9d5f6b (svn r1478802), both 2013-05-03. It created heapSort/heapify/siftDown and heapChild in int, and made heap sort IntroSorter's fallback; the predecessor SorterTemplate fell back to merge sort with overflow-safe midpoints. LUCENE-7097 (6.1.0) and LUCENE-10196 (8.11.0) changed the depth budget and partitioning but kept the fallback; the heap lines are byte-identical from 4.4.0 to main. All 116 release tags from 4.4.0 on (to 10.6.0) contain the commit or its backport, with byte-identical heapChild and loop-guard lines. Not run one by one: 8.8.2 to 8.11.3.

Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20 affectedminimal test fails with assertions on and off (standalone and gradle); passes with either fix
10.6.0 affectedminimal test fails with assertions on and off
10.5.2 affectedminimal test fails; ArrayUtil.introSort on a real Integer[MAX_ARRAY_LENGTH] throws AIOOBE
9.12.3 affectedminimal test fails with assertions on and off
9.1.0 affectedminimal test fails (assertions off; fact check)
4.10.4, 5.5.5, 6.0.0, 6.6.6, 7.7.3, 8.11.4, 9.0.0, 10.0.0 affectedthe hunt's direct heapSort port and public-API port (an IntroSorter subclass in another package) both fail
4.4.0 first affectedboth ports fail with assertions on and off; the direct port at the same slot as main
4.3.1 no such APIno Sorter/IntroSorter; the SorterTemplate analog stays inside the slice

Independent checks

Confirmed with high confidence: an independent reproducer, an intended-behaviour devil's advocate and an adjudication all reproduced it, and a reviewer from another model family (OpenAI GPT-5.6) rated the not-a-bug case weak. Nobody found it intended; all agree it is a rare, loud failure.

Checks and verdicts271 words

Re-run at registry time, in a credibility audit, on a later main and after minimisation: still reproduces.

Each independent check and its verdict
CheckVerdictWhat it found
finder (Z3 + JUnit) candidateZ3 found and characterised the wrap; a same-package JUnit fails on main (assertions on and off), 10.5.2 and 9.12.3; its slot-0 control passes; the fix passes.
cross-model review (OpenAI GPT-5.6, refute lens) no verdict3 attempts gave no verdict; not a refutation.
independent reproducer realOwn tests fail 6 of 11 on main, 10.5.2 and 9.12.3, all 5 controls pass; public-API path and the real Integer[MAX_ARRAY_LENGTH] AIOOBE on main and 10.5.2; a long-arithmetic fix passes.
cross-model adversary (OpenAI GPT-5.6, intended lens, reading only) real, mediumRated the works-as-designed case weak; a separate public-API probe fails on main and 10.5.2 and passes with the fix.
intended-behaviour devil's advocate real, highNo not-a-bug argument survived; re-ran the repros on main, 10.5.2 and 9.12.3.
adjudication confirmed, highRe-ran the finder repro (assertions on and off) and the public test; class crash.
audit, refresh and minimisation confirmedCredibility audit kept it; still reproduces on main a9779ef3c193; minimised to the 60-value test, which fails on 645d4fe14a20 and passes with the fix.
fact check (2026-11-01) real; wording fixedOne reviewer re-ran the minimal test (standalone and gradle, assertions on and off), both fixes, 10.6.0, 9.12.3, 9.1.0, the real-array run on 645d4fe14a20 and the Z3 model; a reviewer from another model family (OpenAI GPT-5.6) read the evidence without running code. Both confirmed the bug; the wording errors and overclaims they found are fixed in this doc.

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

Finding record

LFV-372, snapshot of 2026-11-06

Registry title
Sorter.heapChild overflows int, so IntroSorter's heap-sort fallback compares a negative slot outside [from, to) when 2*to - from > 2^31 (ArrayIndexOutOfBoundsException from ArrayUtil.introSort on a legal array slice)
Area
util / sorters (Sorter heap sort, IntroSorter's depth fallback)
Priority
Low, rank 1064: An internal sorter's heap-sort fallback throws only on slices of more than 2^30 elements with adversarial ordering, a practically unreachable loud overflow.
Impact class
crash
Impact note
Only sorts whose slice satisfies 2*to - from > 2^31 (more than 2^30 = 1,073,741,824 slots starting at 0, or a slice that ends near Integer.MAX_VALUE or ArrayUtil.MAX_ARRAY_LENGTH) and whose element order uses up IntroSorter's 2*log2(n) quicksort depth budget so that the heap-sort fallback runs (executed with a quicksort-adversary order; random data in the same slice never reached the fallback), through the @lucene.internal IntroSorter or ArrayUtil.introSort called by application code [exec] (CollectionUtil.introSort by reading); no in-tree Lucene caller was shown to reach it; array-backed sorts always throw at the first out-of-slice compare, before any out-of-slice swap, while a custom Sorter subclass is handed a negative slot; no setting is needed, -ea and -da behave the same, and every release from 4.4.0 to 10.6.0 and main is affected.
Summary
You sort a legal slice with IntroSorter, for example ArrayUtil.introSort on the last 1,000 elements of an array of length ArrayUtil.MAX_ARRAY_LENGTH (or a slice of more than 2^30 elements starting at 0), and the element order makes IntroSorter give up on quicksort and fall back to heap sort. You expect the slice [from, to) to be sorted using only slots inside it. Instead Sorter.heapChild computes 2*(i - from) + 1 + from in int, which wraps to a negative number for a node that has no child, siftDown's 'leftChild < to' check lets it through, and compare is called with a negative slot, so the array-backed sort throws ArrayIndexOutOfBoundsException ('Index -2147482757 out of bounds for length 2147483631').
Introduced
b895ebde4340 (LUCENE-4946 'Refactor SorterTemplate (now Sorter)', by GitHub jpountz, svn r1478785 on trunk; branch_4x backport fd331e9d5f6b, svn r1478802, which the 4.x releases contain; committed from JIRA patches, no GitHub PR; the issue is now #6010) [read: git log -L and git blame on heapChild, siftDown and heapSort list only this commit; it created Sorter with heapSort/heapify/siftDown and heapChild in int arithmetic and made heap sort IntroSorter's depth fallback, while the predecessor SorterTemplate fell back to merge sort with overflow-safe midpoints] [exec: 4.3.1 has no Sorter or IntroSorter (compile error) and its SorterTemplate analog stays inside the slice at the same slots; 4.4.0 fails at the same slot as main]. Not a regression: LUCENE-7097 (8cbe4713775) and LUCENE-10196 (63b9e603e6f) changed IntroSorter's depth budget and partitioning but kept the heap-sort fallback, and the heap arithmetic is unchanged since 2013 (2013-05-03 (b895ebde4340 and its backport fd331e9d5f6b))
First affected version
4.4.0 [exec] (release tag 2013-07-22; the first release with Sorter and IntroSorter, via the branch_4x backport); 4.3.1, the last release before, has no such API [exec: compile error], and its SorterTemplate predecessor stays inside the slice at the same slots [exec]
Affected versions
every release from 4.4.0 through 10.6.0 and main (ea57360912ed, 645d4fe14a20); no version has a fix. [exec: a direct heapSort port and a public-API port (IntroSorter subclass in another package, McIlroy quicksort adversary built against each version) both fail on 4.4.0 (-ea and -da), 4.10.4, 5.5.5, 6.0.0, 6.6.6, 7.7.3, 8.11.4, 9.0.0, 9.12.3 (-ea and -da), 10.0.0, 10.5.2 (-ea and -da) and main (-ea and -da), with their controls passing; the finder's JUnit fails on main, 10.5.2 and 9.12.3, the independent reproduction's tests on main, 10.5.2 and 9.12.3; ArrayUtil.introSort on a real Integer[MAX_ARRAY_LENGTH] throws on main and 10.5.2 (-da)] [read: all 116 release tags from 4.4.0 to 10.6.0 in the history clone contain the commit or its backport, and the heapChild and siftDown loop-guard lines are byte-identical in all 116 (rechecked 2026-11-01)]; 8.8.2 to 8.11.3 were not executed one by one (tags not in the history clone; 8.11.4 fails); 10.6.0 (released 2026-10-29): the minimal repro fails with -ea and -da [exec, SHA-1-checked release jars, 2026-11-01], as does 9.1.0 (-da) [exec]. Caveat: every run used JDK 25.0.3
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
formal: Z3
Upstream and prior reports
none found (2026-10-26: two scripted prior-report searches with 60 queries (32 mailing-list queries over dev@, java-user@, issues@ and commits@, 10 JIRA-archive, 13 GitHub issue/PR, 3 commit and 2 downstream; 66 and 18 distinct hits, 0 high), the verifiers' earlier runs (the independent reproducer's 31 queries, the intended-behaviour check's 43 queries plus direct list searches; 1 high hit, #8152, read: not this bug), 56 GitHub issue/PR searches over apache/lucene, apache/lucene-solr, apache/lucenenet, apache/solr, elastic/elasticsearch, opensearch-project/OpenSearch and org:apache, the changed files of all 411 open apache/lucene PRs (5 touch the sorter files, none the heap code), the upstream and local commit history (git log --all -S heapChild finds only the LUCENE-4946 commits) and 6 web queries; 23 hits read, none the same bug); not fixed upstream (apache/lucene main e1c4184cb983 on 2026-10-26: Sorter.java last changed by #426 in 2021, heapChild and siftDown unchanged since 2013). Closest items, not reports: #6010 (LUCENE-4946, the introducing issue; its review discussed the exclusive end index overflowing at 2^31 when sorting a full array, answered 'no real issue as the maximum array size is limited', and never mentions heapChild, siftDown or heap sort), #8152 (LUCENE-7097, depth budget, performance only), #7355 (LUCENE-6293, a fixed TimSorter AIOOBE at extreme scale, precedent), #9866 (LUCENE-8822, open, an UnsupportedOperationException from MSBRadixSorter.compare under heapify/siftDown, a different symptom), #11477 (LUCENE-10441, open, an AIOOBE in BytesRefHash.get through binarySort, not the heap path) and open PR #12800 (makes binarySort protected; does not touch the heap code)
Lucene commit tested
main 645d4fe14a20 (the minimal test standalone with assertions on and off, also with a 24-byte array header, and through gradle; both fixes in a scratch worktree of 645d4fe14a20); the SHA-1-checked release jars 10.6.0, 10.5.2, 9.12.3 and 9.1.0 (the minimal test; 9.1.0 in the fact check, which also re-ran ArrayUtil.introSort on a real Integer[MAX_ARRAY_LENGTH] on 645d4fe14a20). Earlier, on main ea57360912ed and a9779ef3c193: the hunt's direct and public-API tests on 12 versions from 4.4.0 to main, and ArrayUtil.introSort on a real Integer[MAX_ARRAY_LENGTH] on main and 10.5.2.

Lucene and upstream sources

Finding record
  • lucene/core/src/java/org/apache/lucene/util/Sorter.java:36 (at ea57360912ed) (@lucene.internal), :75-79 (sort javadoc: 'the slice which starts at from (inclusive) and ends at to (exclusive)'), :81-86 (checkRange rejects only to < from), :254-268 (heapSort javadoc 'used as a fall-back by IntroSorter'; body), :270-274 (heapify), :276-294 (siftDown: :277 'leftChild < to' guard, :279 compare(i, leftChild) before any swap), :296-298 (heapParent with >>> 1), :300-302 (heapChild = ((i - from) << 1) + 1 + from in int)
  • lucene/core/src/java/org/apache/lucene/util/IntroSorter.java:41-45 (at ea57360912ed) (sort: depth budget 2 * MathUtil.log(to - from, 2)), :47-62 (fallback 'Max recursion depth exceeded: fallback to heap sort' at :58-61)
  • lucene/core/src/java/org/apache/lucene/util/ArrayUtil.java:509-520 (at ea57360912ed) (introSort(T[], fromIndex, toIndex, comp): 'Sorts the given array slice', no size limit), :32-34 (MAX_ARRAY_LENGTH)
The finding in 30 seconds
  • lucene/core/src/java/org/apache/lucene/util/IntroSorter.java:19-25, :42-45, :59-61, :67, :155-158 (at 645d4fe14a20)
  • lucene/core/src/java/org/apache/lucene/util/Sorter.java:165-176, :240-241, :254-302; TimSorter.java:183; ArrayIntroSorter.java:40-42; InPlaceMergeSorter.java:44; MathUtil.java:32-35 (at 645d4fe14a20)
Who is affected
  • lucene/core/src/java/org/apache/lucene/util/ArrayUtil.java:26, :32-34, :509-520; CollectionUtil.java:29, :133; Sorter.java:36; IntroSorter.java:31 (at 645d4fe14a20)
  • lucene/core/src/java/org/apache/lucene/index/DocValuesFieldUpdates.java:297-337, :376-378 (at 645d4fe14a20)
Why it looks unintended
  • lucene/core/src/java/org/apache/lucene/util/Sorter.java:36, :48-55, :75-79, :81-86, :254-258; ArrayUtil.java:32-34, :509-520; IntroSorter.java:19-25; package-info.java:35 (at 645d4fe14a20)
Suggested fix
  • OpenJDK 25.0.3 src.zip java.base/java/util/PriorityQueue.java:686-687 (siftDownComparable guard)
  • SECURITY.md:46 (robustness fixes must not slow hot paths)
History
  • a Lucene clone: git show b895ebde4340, fd331e9d5f6b; git log -L on heapChild and siftDown

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. Its numbers come from its own probe on 645d4fe14a20, run with assertions on and off: a replay of the minimal repro's 60 values (the same heap-sort range and wrapped child as the trace in this doc), and ArrayUtil.introSort on a real Integer[MAX_ARRAY_LENGTH] whose last 60 slots hold those values (10 GB heap), which throws Index -2147483639 out of bounds for length 2147483631, the message on the summary card. The real-array run in this doc uses a 1,000-value tail and gives Index -2147482757.

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.