All findings · Lucene finding · LFV-1147 · fstenum-rewindprefix-label-subtraction-overflow

A reused IntsRefFSTEnum returns a wrong seekCeil/seekFloor after a term with label Integer.MAX_VALUE

A reused IntsRefFSTEnum on [3, Integer.MAX_VALUE] answers seekCeil([3]) with [3, MAX] and seekFloor([3]) with null, although [3] is in the FST: it compares labels by subtraction, and MAX - (-1) overflows. With in-range seek targets, only expert-API users who store that label are hit; every release since 3.3.

Summary

Priority
Low, rank 929A reused IntsRefFSTEnum returns a wrong seekCeil or seekFloor result after a term with the label Integer.MAX_VALUE, in every release since 3.3. It needs that exact label in an application-built BYTE4 FST of the expert FST API, which no Lucene caller uses, so its reach is very narrow.
Impact
minor
Since
3.3 (LUCENE-2843 / LUCENE-3140, 2011)introduced 2011-01-05 · over 15 years
Affected
3.3 to 10.6.0 and main (7 releases run, all fail); not fixed upstream
Status
documented
Found by
semi-formal: a proof-obligation audit of FSTEnum's seek contract, witnessed by an executable repro

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

The finding in 30 seconds

Background: FST enumerators and seeking

  1. An FST maps sorted keys to outputs. With input type BYTE4 a key is a sequence of int labels, each any value >= 0. Lucene's own BYTE4 FSTs (synonyms, stemmer overrides, hunspell) store Unicode code points.
  2. IntsRefFSTEnum walks the keys in order, like a SortedMap iterator. seekCeil(t) moves to the smallest key >= t; seekFloor(t) to the largest key <= t.
  3. The same enum can seek again, forward or backward. To save work it first keeps the labels its current key shares with the new target, then searches only the rest. Where the target has no more labels, the enum uses the marker label -1 (END_LABEL).
Diagram of the bug: a BYTE4 FST with keys [3] (output 1) and [3, 2147483647] (output 2). The node after label 3 is final and has one arc, 2147483647. An enum positioned on [3, 2147483647] seeks the ceiling of [3]. At position 2 the target has ended (label -1); the subtraction 2147483647 - (-1) overflows to -2147483648, the enum keeps the 2147483647 arc and returns [3, 2147483647] instead of [3].
The enum stands on [3, MAX] and is asked for the ceiling of [3]. At the second label it compares MAX with the target's end marker -1 by subtraction: 2147483647 - (-1) wraps to -2147483648, so it keeps the MAX arc and returns [3, MAX]. Integer.compare gives +1, resets to the node's first arc and returns [3]. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. Build a BYTE4 FST with two keys: [3] -> 1 and [3, 2147483647] -> 2. 2147483647 is Integer.MAX_VALUE, a legal BYTE4 label.
  2. On one IntsRefFSTEnum, call seekCeil([3, MAX]): it correctly lands on [3, MAX]. Then call seekCeil([3]) on the same enum.
  3. You expect [3] with output 1: it exists, so it is its own ceiling, and a fresh enum returns exactly that.
  4. Instead you get [3, MAX] with output 2, and seekFloor([3]) returns null. No error, with assertions on or off.
  5. Why: to reuse its position, the enum compares its current label with the target's label by subtracting them. The target [3] has run out, which the enum marks as label -1, and 2147483647 - (-1) wraps to -2147483648. So the enum thinks the target lies ahead and keeps the MAX arc.

Who is affected

With in-range seek targets, only application code that puts the label Integer.MAX_VALUE into its own BYTE4 FST and reuses one IntsRefFSTEnum to seek back to a prefix of its current key; also a reused enum given a target with a huge negative label. No Lucene component does either. Every release from 3.3 to 10.6.0.

Mechanism, terms and numbers613 words

IntsRefFSTEnum.seekCeil and seekFloor begin with FSTEnum.rewindPrefix(), which keeps the part of the current term that the new target shares and decides, label by label, whether to seek forward or reset to the node's first arc. It decides with final int cmp = getCurrentLabel() - getTargetLabel(); (FSTEnum.java:79 at 5adff78bb168). Once the target is used up, getTargetLabel() returns FST.END_LABEL, -1 (IntsRefFSTEnum.java:91-97). If the current label there is Integer.MAX_VALUE, a legal BYTE4 label, 2147483647 - (-1) wraps to Integer.MIN_VALUE, the code takes the "seek forward" branch and keeps the MAX arc. In the BYTE4 FST {[3] -> 1, [3, MAX] -> 2}, an enum positioned on [3, MAX] answers seekCeil([3]) with [3, MAX] and seekFloor([3]) with null; a fresh enum answers both correctly. The line dates from 2011 (LUCENE-2843) and is in every release from 3.3 to 10.6.0. No Lucene component builds a BYTE4 FST with that label; the fix is one Integer.compare.

The mechanism (main 5adff78bb168, lucene/core/src/java/org/apache/lucene/util/fst/)

  1. IntsRefFSTEnum.seekCeil (IntsRefFSTEnum.java:60-65) stores the target and calls doSeekCeil, which first calls rewindPrefix() (FSTEnum.java:136) "to match the shared prefix between current term and target term" (:66).
  2. rewindPrefix (FSTEnum.java:67-94) walks positions upto = 1, 2, ... of the current term and at :79 (the fix site) computes cmp = getCurrentLabel() - getTargetLabel(). cmp < 0 ("seek forward") breaks and keeps the current arc (:80-83); cmp > 0 ("seek backwards") resets this position to the node's first arc (:84-89).
  3. Current term [3, MAX], target [3]. Position 1: 3 - 3 = 0, go on. Position 2: the target is used up, so getTargetLabel() returns FST.END_LABEL = -1 (IntsRefFSTEnum.java:93-94; FST.java:139). The right answer is MAX > -1, reset. But 2147483647 - (-1) = 2147483648 does not fit in an int and wraps to -2147483648, so cmp < 0 and the MAX arc is kept.
  4. doSeekCeil's scan (FSTEnum.java:139-161) continues from the kept arc with target label -1. On this list-encoded node doSeekCeilList sees label MAX > -1 and calls pushFirst() (:286-288), which completes [3, MAX]. After a reset, the node's first arc would be the final (END_LABEL) arc, which matches -1 and gives [3].
  5. doSeekFloor calls the same rewindPrefix (:328): seekFloor([3]) returns null (executed). doSeekExact also calls it (:653) but then finds the arc with findTargetArc, so seekExact is correct (executed). Positioning on [3, MAX] with two next() calls instead of seekCeil triggers it too (executed).

Why only this one value (with in-range targets)

The labels an FST stores are >= 0 (asserted in FSTCompiler.java:581 and :1109; Java asserts only, so nothing checks it with assertions off), and a target made of such labels adds only END_LABEL = -1 as a negative value, so the subtraction can overflow only for Integer.MAX_VALUE - (-1). With label 0x7ffffffe the same reused-enum sequence is correct: reusedCeil=[3]/1 reusedFloor=[3]/1; with 0x7fffffff: reusedCeil=[3 7fffffff]/2 reusedFloor=null. BytesRefFSTEnum labels are 0-255 and cannot overflow.

Out-of-range targets. seekCeil/seekFloor do not check the target: getTargetLabel() returns the caller's own ints (IntsRefFSTEnum.java:96), and no javadoc states a label range for seek targets. A target label at least 2^31 below the current label wraps the same line. On the BYTE4 FST {[1] -> 1, [3] -> 2} (no MAX label), an enum first positioned by seekCeil([3]) answers seekCeil([Integer.MIN_VALUE]) and seekCeil([-2147483645]) with [3]/2, while a fresh enum gives [1]/1; -2147483644 (no wrap) is correct; the Integer.compare line fixes both (executed). A side remark, not a second repro.

Exposure and likelihood218 words
  • Who. Application code that builds its own BYTE4 FST containing the label Integer.MAX_VALUE and reuses one IntsRefFSTEnum to seekCeil or seekFloor to a proper prefix of its current term whose next label is that MAX label. Two keys suffice; no scale, default settings. Outside the label range, also code that seeks a reused IntsRefFSTEnum with a target holding a huge negative label (seekCeil, executed).
  • Effect. A silent wrong lookup: seekCeil returns a term (and its output) that is not the ceiling; seekFloor returns null for a target that exists. Same with assertions on and off.
  • Not affected. Lucene's BYTE4 FSTs hold code points <= 0x10FFFF (SynonymMap.java:244, StemmerOverrideFilter.java:214, hunspell Dictionary.java:648 in lucene/analysis/common). The only non-test IntsRefFSTEnum user, hunspell WordFormGenerator.java:68-71, calls next() only. The codecs' term readers use BytesRefFSTEnum (byte labels). A fresh enum and seekExact are correct.
  • Opt-in part. FST, FSTCompiler and IntsRefFSTEnum are @lucene.experimental (FST.java:62, FSTCompiler.java:68, IntsRefFSTEnum.java:26).
  • Likelihood: rare. No caller found does it and no public report of it was found since 2011. It is deterministic, though: whoever uses MAX as a label, reuses the enum and then seeks to a proper prefix whose next current label is MAX gets the wrong answer every time; other in-range seeks after a MAX term compare without overflow.

Minimal repro

One JUnit test method on the public FST classes: a two-key BYTE4 FST, two seekCeil calls on one enum, one assertion. Deterministic, single-threaded, about 1 s; it fails with assertions on and off.

TestFSTEnumSeekAfterMaxLabel.java (lucene/core, package org.apache.lucene.util.fst, 1 test)38 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.fst;

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

public class TestFSTEnumSeekAfterMaxLabel extends LuceneTestCase {

  public void testSeekCeilToPrefixAfterMaxLabel() throws Exception {
    IntsRef shortTerm = new IntsRef(new int[] {3}, 0, 1);
    IntsRef longTerm = new IntsRef(new int[] {3, Integer.MAX_VALUE}, 0, 2);
    FSTCompiler<Long> compiler =
        new FSTCompiler.Builder<>(FST.INPUT_TYPE.BYTE4, PositiveIntOutputs.getSingleton()).build();
    compiler.add(shortTerm, 1L);
    compiler.add(longTerm, 2L);
    FST<Long> fst = FST.fromFSTReader(compiler.compile(), compiler.getFSTReader());

    IntsRefFSTEnum<Long> fstEnum = new IntsRefFSTEnum<>(fst);
    fstEnum.seekCeil(longTerm);
    // expected: [3] (it exists, so it is its own ceiling); actual: [3, 7fffffff]
    assertEquals(shortTerm, fstEnum.seekCeil(shortTerm).input);
  }
}
Command
cp $REPRO/TestFSTEnumSeekAfterMaxLabel.java $LUCENE/lucene/core/src/test/org/apache/lucene/util/fst/
cd $LUCENE && ./gradlew :lucene:core:test --tests org.apache.lucene.util.fst.TestFSTEnumSeekAfterMaxLabel -Ptests.seed=DEADBEEF
# and with -Ptests.asserts=false: the same failure
Expected
[3], as a fresh enum returns.
Observed
[3, 7fffffff], a key above the ceiling: java.lang.AssertionError: expected:<[3]> but was:<[3 7fffffff]>.

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

More files, environment, full output197 words

TestFSTEnumSeekAfterMaxLabel (one method, extends LuceneTestCase, package org.apache.lucene.util.fst of the lucene/core tests). It uses the public FSTCompiler, FST and IntsRefFSTEnum and builds its keys as IntsRef, which is @lucene.internal but is the type the enum's seek methods take. It builds {[3] -> 1, [3, Integer.MAX_VALUE] -> 2} with FSTCompiler.Builder(BYTE4, PositiveIntOutputs), calls seekCeil([3, MAX]) and then asserts that seekCeil([3]) on the same enum returns [3].

As the regression test, the same method fits in TestFSTs. seekFloor([3]) on the same enum returns null too; the test asserts only the seekCeil result. IntsRef.toString prints labels in hex, so 7fffffff is Integer.MAX_VALUE.

Environment

lucenemain 5adff78bb168 (12.0.0-SNAPSHOT): fails; 9.12.3, 10.5.2, 10.6.0: the same test fails; 3.3.0 to 8.11.4 (4 releases): an older-API form fails; 3.2.0: no FST package
jdkOpenJDK 25.0.3 (build 25.0.3+9-LTS)
osLinux aarch64 (nothing platform-specific)
Command (environment)
cp $REPRO/TestFSTEnumSeekAfterMaxLabel.java $LUCENE/lucene/core/src/test/org/apache/lucene/util/fst/
cd $LUCENE && ./gradlew :lucene:core:test --tests org.apache.lucene.util.fst.TestFSTEnumSeekAfterMaxLabel -Ptests.seed=DEADBEEF
# and with -Ptests.asserts=false: the same failure

Observed output

TestFSTEnumSeekAfterMaxLabel > testSeekCeilToPrefixAfterMaxLabel FAILED
    java.lang.AssertionError: expected:<[3]> but was:<[3 7fffffff]>
        at __randomizedtesting.SeedInfo.seed([DEADBEEF:40F03DFA408425C1]:0)
        at org.junit.Assert.fail(Assert.java:89)
        at org.junit.Assert.failNotEquals(Assert.java:835)
        at org.junit.Assert.assertEquals(Assert.java:120)
        at org.junit.Assert.assertEquals(Assert.java:146)
        at org.apache.lucene.util.fst.TestFSTEnumSeekAfterMaxLabel.testSeekCeilToPrefixAfterMaxLabel(TestFSTEnumSeekAfterMaxLabel.java:36)

(gradle on main 5adff78bb168, seed DEADBEEF, assertions on; with -Ptests.asserts=false the same assertion)

Why it looks unintended

  • seekCeil promises the "smallest term that's >= target" and seekFloor the "biggest term that's <= target", with no fresh-enum rule; a fresh enum is correct.
  • INPUT_TYPE documents the "allowed range of each int input label"; BYTE4 accepts any label >= 0.
  • Backward seeks are designed: rewindPrefix has a seek-backwards branch, and Lucene's FST tests reuse one enum for random-order seeks.
The full argument238 words
  • Contract. IntsRefFSTEnum.java:59: "Seeks to smallest term that's >= target"; :67: "Seeks to biggest term that's <= target". The package doc says the enumerators "behave like SortedMap iterators" (package-info.java:30-32) and "Like TermsEnum, this also supports seeking (advance)" (:73).
  • Legal label. FST.java:68: INPUT_TYPE "Specifies allowed range of each int input label for this FST". FSTCompiler.writeLabel asserts BYTE1 <= 255 and BYTE2 <= 65535 but for BYTE4 only v >= 0, written as a vInt (FSTCompiler.java:580-590; Java asserts, not run-time checks). Input order is IntsRef.compareTo, a signed Arrays.compare (IntsRef.java:106-107), so MAX is just the largest label. Michael McCandless described the input type the same way on LUCENE-3695 (#4769): "The "INPUT_TYPE" really describes the allowed range of the input int labels", and "FST is just like a SortedMap".
  • Backward seeks. rewindPrefix exists to reuse the current position and has a "seek backwards -- reset this arc to the first arc" branch (FSTEnum.java:84-89). FSTTester in the test framework reuses one IntsRefFSTEnum for seekCeil, seekFloor and seekExact on random targets in random order (FSTTester.java:420-520), only with byte and code-point labels, so it never reaches the overflow.
  • History. Before LUCENE-2843 the forward-only advance(target) (never released) said "NOTE: target must be >= where we are already positioned"; LUCENE-2843 replaced it with seekCeil/seekFloor without such a note. No commit, issue or PR since calls the subtraction deliberate.
The devil's-advocate check143 words

The strongest case for "intended": the Builder javadoc says strings are represented as BYTE4 "full unicode codepoints" (FSTCompiler.java:253-255), every in-tree BYTE4 FST stays <= 0x10FFFF, the API is @lucene.experimental, and IntsRef itself is @lucene.internal (IntsRef.java:25); so labels near 2^31 are off-label.

Why it fails: the same javadoc says the input type "Can be anything from INPUT_TYPE enumeration"; the code-points sentence is an example of use, and no narrower BYTE4 range is documented or enforced. @lucene.experimental allows API changes, not wrong answers; IntsRefFSTEnum's public seek methods take an IntsRef, so every caller of them builds one; and the failure is silent rather than a clear rejection. The cross-model reviewer rated this case moderate and rejected it, as did the independent reproducer, the devil's-advocate verifier and the adjudication. A maintainer who prefers to restrict labels has fix B.

Suggested fix

Recommended Compare, do not subtract (fix A)

Compare the labels with Integer.compare(getCurrentLabel(), getTargetLabel()) instead of subtracting them: one line in FSTEnum.rewindPrefix. The repro passes with assertions on and off, and the selected FST and FST-based postings-format suites show 0 failures (188 tests, 43 skipped). Open PR #14477 rewrites the same loop.

-      final int cmp = getCurrentLabel() - getTargetLabel();
+      final int cmp = Integer.compare(getCurrentLabel(), getTargetLabel());
All options and trade-offs2 options ยท 491 words

Fix A was built and checked through gradle on 5adff78bb168; B was not run. Open PR #14477 ("Use Arrays#mismatch in FSTEnum#rewindPrefix", a performance change) keeps getCurrentLabel() - getTargetLabel() for the first mismatching label, so the same change belongs there if it lands first. Open PR #12688 copies the compare into a new PrimitiveLongFSTEnum that only sees byte labels and cannot overflow, so a change there would only be for consistency. Scope: fix A covers the in-range case (MAX against END_LABEL) and the executed out-of-range negative-target case (a list-encoded root node). On array-encoded nodes the seek code does more label arithmetic on the target (FSTEnum.java:166, :196, :362, :393, :507, :529; FST.java:1047, :1068, :1085; Util.java:917), which was not tested with such targets; whether seek targets must hold labels >= 0 is for the maintainers to state, as nothing documents or checks it today.

Compare, do not subtract (fix A) (validated by the checks) (recommended)

In FSTEnum.rewindPrefix (FSTEnum.java:79), replace the subtraction with Integer.compare.

-      final int cmp = getCurrentLabel() - getTargetLabel();
+      final int cmp = Integer.compare(getCurrentLabel(), getTargetLabel());

Trade-offs

No functional change for any pair of labels that does not overflow: the sign is the same as before. Not benchmarked; the JMH FSTEnumSeekBenchmark of open PR #16095 could measure it.

What the checks validated

  • Gradle in a scratch worktree at 5adff78bb168, seed DEADBEEF: the repro fails without the fix (assertions on, and -Ptests.asserts=false) and passes with it (both).
  • With the fix, the org.apache.lucene.util.fst test classes in core (the repro, TestFSTs 22, TestUtil 5, TestFSTDirectAddressing 3 and 5 more classes) and, in codecs, TestFSTPostingsFormat, TestVarGapDocFreqIntervalPostingsFormat, TestVarGapFixedIntervalPostingsFormat and TestUniformSplitPostingFormat (30 each, 2 skipped each) ran, plus TestSimpleTextPostingsFormat, which is @Nightly, so all 30 of its tests were skipped: 188 tests, 0 failures, 43 skipped (also both 2B tests), so 145 ran and passed. checkGoogleJavaFormat on core passes. These suites are regression evidence only: the codec suites use BytesRefFSTEnum (labels 0-255), which cannot reach the overflow. The full core suite was not run, and only OpenJDK 25.0.3 on Linux aarch64 was used.
  • Earlier: the hunt's shadow-class runs of the same line pass the repro (assertions on and off); the independent reproducer's and the cross-model reviewer's own tests pass with it; on the 3.3.0 jar the same one-line change turns the failure into a pass.
  • The fact check's out-of-range probe (seekCeil([Integer.MIN_VALUE]) on a reused enum over {[1], [3]}) gives [1]/1 with the fix, as a fresh enum does (executed, assertions off).

Restrict BYTE4 labels (fix B, not recommended) (not run)

Document BYTE4 labels as < Integer.MAX_VALUE (or as code points) and reject larger labels in FSTCompiler.

Trade-offs

An API restriction instead of a bug fix: it breaks applications that already use MAX, does nothing for FSTs already written with it, and is more code than A.

What the checks validated

Not run.

Downstream note

Downstream code that uses Integer.MAX_VALUE as a label in its own BYTE4 FST and reuses an IntsRefFSTEnum would silently get wrong ceiling and floor lookups, as would code that passes a seek target with a huge negative label to a reused enum; no Lucene component builds such an FST or seeks an IntsRefFSTEnum.

How it was found

A proof-obligation audit of FSTEnum's seek contract: for every label >= 0, a seek on a reused enum must match a fresh enum. It fails where an int subtraction stands in for a comparison, at one in-range pair, MAX against the end marker -1; a JUnit test witnessed it. A later fact check: huge negative target labels wrap too.

The method in full161 words

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

  • The finder checked rewindPrefix against the obligation that seekCeil/seekFloor on a reused enum equal the fresh-enum answer for every label BYTE4 accepts (any v >= 0) and found that cmp = getCurrentLabel() - getTargetLabel() changes sign for Integer.MAX_VALUE - (-1).
  • The finder then wrote a JUnit test and the one-line Integer.compare fix that makes it pass; the repro was later cut to a single method with two keys.
  • An independent reproducer, an executed cross-model review and a devil's-advocate verifier reproduced it with their own tests, and an adjudication re-ran the finder's and the reproducer's tests ("Prior reports, history and checks").
  • The audit took target labels to be >= 0 as well. The doc's fact check later ran a reused enum with a huge negative target label (Integer.MIN_VALUE) and showed that it wraps the same line; the same fix covers that run.

Prior reports, history and checks

Prior reports

No prior report found in apache/lucene issues, PRs and commits (incl. the JIRA archive), the mailing lists, downstream trackers or the web (last checked 2026-11-03). Nearest: open PR #14477, a performance rewrite of the same loop that keeps the subtraction and never mentions overflow.

Searches3 searches

No matching public report was found in the documented searches. Nearest items, none a report of this bug: #14477 (open, "Use Arrays#mismatch in FSTEnum#rewindPrefix", keeps the subtraction), #16095 (open, its benchmark), #12688 (open, copies the compare for byte labels), #3917 (LUCENE-2843, where the code was written) and #6094 (LUCENE-5030: a 2013 comment proposed Integer.MAX_VALUE as a sentinel label, dropped because those labels must pass through UTF32ToUTF8). Lucene.NET's FSTEnum.cs has the same subtraction; no Lucene.NET issue mentions it. Limits: keyword search; private reports are not visible.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs and commits (apache/lucene incl. the migrated JIRA archive)2026-11-02; re-checked the same day and on 2026-11-03a scripted prior-report search (183 distinct hits, none high) and about 30 manual searches: rewindPrefix, getTargetLabel, IntsRefFSTEnum, BYTE4, seekCeil/seekFloor with overflow, Integer.MAX_VALUE, Integer.compareno report; #14477 is the only change to the same loop
Open and recent PRs2026-11-02 and 2026-11-03the changed files of all 409 open apache/lucene PRs, of the 34 PRs updated since 2026-10-31 and of the 11 updated since 2026-11-02only #14477 (and the benchmark #16095) touch FSTEnum; none fixes it
Mailing lists, downstream, web2026-11-02dev@, java-user@, issues@ and commits@; Elasticsearch, OpenSearch, Solr, Lucene.NET and the archived lucene-solr; GitHub code search; web queriesnothing about this bug

History

Present since LUCENE-2843 wrote the generic seek (1cab313a2b8, 2011), first released in 3.3 through the branch_3x copy; 3.2.0 has no FST package. Every release run since then, through 10.6.0 and main, fails. Not fixed upstream.

Versions tested4 rows

Introduced by LUCENE-2843 "Add variable-gap terms index impl." (Michael McCandless; now #3917): 1cab313a2b8 on trunk, 2011-01-05, which created FSTEnum with seekCeil, seekFloor and rewindPrefix. The branch_3x copy came with LUCENE-3140 (#4213), 43c373b207e, 2011-05-29; first release 3.3, and 4.0.0-alpha on the trunk line. Four later commits touched rewindPrefix (LUCENE-3225, LUCENE-4117, LUCENE-8920, the LUCENE-9570 reformat); none changed the compare.

Releases before 9.10 ran main() versions of the same scenario with the older FST API. On the 3.3.0 jar, changing only this compare to Integer.compare turns the failure into a pass. By reading, all 130 release tags from 3.3 to 10.6.0 contain the line. Upstream main 1c2072f0284 (2026-11-02) still has it; the newest commit on FSTEnum.java is 570832eb74a (2023-11-09).

Versions and branches executed, with results
Version or commitVerdictResult
main 5adff78bb168 affectedgradle: fails with assertions on and off; passes with fix A
9.12.3, 10.5.2, 10.6.0 affectedthe JUnit test: [3 7fffffff] instead of [3]
3.3.0, 3.6.2, 4.0.0, 8.11.4 older-API formthe same wrong ceiling; 3.3.0 with only the compare changed passes
3.2.0 API absentcompile error: no FST package

Independent checks

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

Checks and verdicts336 words

The cross-model review ran on the older main ea57360912ed, where FSTEnum.java and IntsRefFSTEnum.java are byte-identical to 5adff78bb168; its test was then re-run on 5adff78bb168 (2 of 3 methods fail with assertions off; all 3 pass with the fix). Every other check ran on 5adff78bb168 or release jars.

Each independent check and its verdict
CheckVerdictWhat it found
finder reproducedTwo-key BYTE4 FST: seekCeil([3]) on the reused enum gives [3 7fffffff] on main (assertions on and off), 10.6.0 and 9.12.3; the one-line fix passes.
independent reproducer realOwn test fails on main (on, off), 10.6.0, 10.5.2 and 9.12.3; controls pass (label MAX-1, fresh enum, seekExact); seekFloor([3]) gives null; next() positioning also triggers it; its own fix passes.
cross-model review, refute lens (OpenAI GPT-5.6, executed) real (high)Own 3-key test ([17], [17, MAX-1], [17, MAX]) fails with assertions on and off; fresh-enum and MAX-1 controls pass; seekFloor gives null; the fix passes it and targeted FST tests.
devil's-advocate verifier realNo documented narrower BYTE4 range and no fresh-enum rule; probe: label 0x7ffffffe correct, 0x7fffffff wrong only on the reused enum; fails on the 10.6.0 jars.
adjudication, separate re-runs, credibility audit and final repro check confirmed (high), LowRe-runs on main fail with assertions on and off and pass with the fix; one minimal deterministic test (two keys, two seeks).
this doc reproduced; fix A validatedGradle without the fix fails (on, off); with it the selected suites show 0 failures (188 tests, 145 ran, 43 skipped).
doc fact checks (independent, and cross-model OpenAI GPT-5.6) bug, repro and fix confirmed; scope wording correctedRe-ran the repro (fail on main, assertions on and off; pass with the fix). Found that a reused enum given a target with a huge negative label wraps the same line (executed; the same fix cures it), so the scope sentences now say "with in-range seek targets"; the SimpleText suite was skipped (@Nightly); IntsRef is @lucene.internal.

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

Finding record

LFV-1147, snapshot of 2026-11-06

Registry title
A reused IntsRefFSTEnum gives wrong seekCeil/seekFloor results after visiting a term with label Integer.MAX_VALUE, because FSTEnum.rewindPrefix compares labels by subtraction and it overflows
Area
util / FST (FSTEnum.rewindPrefix label compare; IntsRefFSTEnum seekCeil/seekFloor)
Priority
Low, rank 929: A reused IntsRefFSTEnum returns a wrong seekCeil or seekFloor result after a term with the label Integer.MAX_VALUE, in every release since 3.3. It needs that exact label in an application-built BYTE4 FST of the expert FST API, which no Lucene caller uses, so its reach is very narrow.
Impact class
minor
Impact note
Only application code that uses the expert, @lucene.experimental FST API directly is hit: in a BYTE4 FST that holds the label Integer.MAX_VALUE, an IntsRefFSTEnum reused to seek to a proper prefix of its current term whose next label is that MAX label silently gets a term above the ceiling from seekCeil, or null from seekFloor although the target exists, with -ea and -da; tiny input, no scale needed, default settings otherwise; no in-tree caller can reach it (in-tree BYTE4 FSTs hold code points, BytesRefFSTEnum labels are <= 255, seekExact is not affected); every release since 3.3 and main.
Summary
You build a BYTE4 FST with [3] -> 1 and [3, Integer.MAX_VALUE] -> 2, move one IntsRefFSTEnum to [3, MAX] with seekCeil, then call seekCeil([3]) on the same enum; you expect [3], the smallest term >= [3], as a fresh enum returns. Instead it returns [3, MAX] (and seekFloor([3]) returns null), with or without assertions. FSTEnum.rewindPrefix compares labels as getCurrentLabel() - getTargetLabel(); once the target is used up its label is END_LABEL (-1), so MAX - (-1) wraps to Integer.MIN_VALUE and the enum keeps the MAX arc instead of going back to the node's first arc.
Introduced
1cab313a2b88d3e1d1365af4e30f06f7a0d49547 (LUCENE-2843, now #3917, 'Add variable-gap terms index impl.', mikemccand, trunk r1055405; branch_3x copy 43c373b207e with LUCENE-3140, now #4213, r1128870) [read: git log -S 'getCurrentLabel() - getTargetLabel()' and git blame -w -M -C -C on the history clone give only these commits, and no later commit changed the line; exec: 3.2.0 has no FST API, 3.3.0 fails with -ea and -da, and 3.3.0 with only the compare changed to Integer.compare passes; the commits and their parents were not built from source (SVN-era builds)] (2011-01-05 (author date of 1cab313a2b8 on trunk; branch_3x copy 43c373b207e: 2011-05-29))
First affected version
3.3 [exec: 3.3.0 fails with -ea and -da; 3.2.0 has no FST package (compile error)]; on the trunk line 4.0.0-alpha (4.0.0 executed: fail)
Affected versions
3.3 to 10.6.0 (released 2026-10-29, 4ae946e5a9b) and main 5adff78bb168 (12.0.0-SNAPSHOT). All 130 release tags from 3.3 to 10.6.0 in the history clone contain the line [read]; 8.8.2-8.11.3 are untagged there and affected by inference (8.8.1 has the line, 8.11.4 fails). Executed, fail [exec]: main -ea and -da (finder, the independent reproduction, the cross-model review re-check, the intended-behaviour check, judge and the registration check), 10.6.0 -ea and -da, 10.5.2 -ea and -da, 9.12.3 -ea and -da, 8.11.4 -da, 4.0.0 -ea and -da, 3.6.2 -da, 3.3.0 -ea and -da (releases before 9.10 through main() adapters with the same scenario and assertions). Not affected: every release before 3.3 (no FST package; 3.2.0 executed: compile error). Not new in 10.6. Not fixed upstream: upstream main 1c2072f0284 (2026-11-02) still has the subtraction at FSTEnum.java:79, and the newest commit on FSTEnum.java is 570832eb74a (author date 2023-11-09). All runs on Linux aarch64 with 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
semi-formal: proof-obligation audit
Upstream and prior reports
none found (2026-11-02, on top of the finder's, the independent reproduction's and the intended-behaviour check's searches; rechecked the same day): a scripted prior-report search over apache/lucene issues, PRs and commits, downstream repositories, the dev@, java-user@, issues@ and commits@ lists and the JIRA archive (183 distinct hits, none high), about 30 manual GitHub searches, 6 web searches, GitHub code search, the changed files of all 409 open apache/lucene PRs and of the 34 PRs updated since 2026-10-31 (none of the recent ones touches util/fst), and full reads of #14477, #16095 and the LUCENE-2843 discussion (#3917). Closest item: open PR #14477 (vsop-479, 'Use Arrays#mismatch in FSTEnum#rewindPrefix', a performance change, created 2025-04-12, last update 2026-06-05) rewrites the same loop but keeps 'getCurrentLabel() - getTargetLabel()' and never mentions overflow. Open PR #12688 copies the same compare into PrimitiveLongFSTEnum (byte labels only). Not fixed anywhere: upstream main 1c2072f0284 still has the line.
Lucene commit tested
main 5adff78bb168 (12.0.0-SNAPSHOT), OpenJDK 25.0.3 on Linux aarch64: this doc's gradle runs (the repro without the fix, assertions on and off: fail; with fix A: pass; 188 selected tests of the closest suites with fix A: 0 failures, 43 skipped) and the hunt's runs; release jars 3.3.0, 3.6.2, 4.0.0, 8.11.4, 9.12.3, 10.5.2 and 10.6.0 (all fail) and 3.2.0 (no FST package). The cross-model review ran on ea57360912ed, whose FSTEnum.java and IntsRefFSTEnum.java are byte-identical to 5adff78bb168, and was re-run on 5adff78bb168.

Lucene and upstream sources

Finding record
  • lucene/core/src/java/org/apache/lucene/util/fst/FSTEnum.java:67-94 (at 5adff78bb168) (rewindPrefix; ':79 final int cmp = getCurrentLabel() - getTargetLabel();'; cmp < 0 breaks at :80-83, the reset to the first arc is at :84-89; callers at :136, :328, :653), lucene/core/src/java/org/apache/lucene/util/fst/IntsRefFSTEnum.java:59-73 (seekCeil 'Seeks to smallest term that's >= target', seekFloor 'Seeks to biggest term that's <= target'), :91-97 (getTargetLabel returns FST.END_LABEL once the target is used up), lucene/core/src/java/org/apache/lucene/util/fst/FST.java:68-73 (INPUT_TYPE 'Specifies allowed range of each int input label'), lucene/core/src/java/org/apache/lucene/util/fst/FSTCompiler.java:580-590 (writeLabel: BYTE4 asserts only v >= 0)
The finding in 30 seconds
  • lucene/core/src/java/org/apache/lucene/util/fst/FSTEnum.java:66-94, (at 5adff78bb168) :136, :139-161, :274-313 (doSeekCeilList; pushFirst at :286-288), :328, :653
  • lucene/core/src/java/org/apache/lucene/util/fst/IntsRefFSTEnum.java:59-73, (at 5adff78bb168) :91-97
  • lucene/core/src/java/org/apache/lucene/util/fst/FST.java:139 (at 5adff78bb168) (END_LABEL = -1); FSTCompiler.java:580-590 (writeLabel)
  • lucene/core/src/java/org/apache/lucene/util/fst/FSTCompiler.java:581, (at 5adff78bb168) :1109 (label asserts); IntsRefFSTEnum.java:96 (target ints returned unchecked)
Who is affected
  • lucene/analysis/common/src/java/org/apache/lucene/analysis/synonym/SynonymMap.java:244, (at 5adff78bb168) .../miscellaneous/StemmerOverrideFilter.java:214, .../hunspell/Dictionary.java:648, .../hunspell/WordFormGenerator.java:68-71
  • lucene/core/src/java/org/apache/lucene/util/fst/FST.java:62, (at 5adff78bb168) IntsRefFSTEnum.java:26 (@lucene.experimental)
Why it looks unintended
  • lucene/core/src/java/org/apache/lucene/util/fst/IntsRefFSTEnum.java:59, (at 5adff78bb168) :67; package-info.java:30-32, :73; FST.java:68; FSTCompiler.java:253-255, :580-590; FSTEnum.java:84-89
  • lucene/core/src/java/org/apache/lucene/util/IntsRef.java:106-107; (at 5adff78bb168) IntsRef.java:25 (@lucene.internal)
  • lucene/test-framework/src/java/org/apache/lucene/tests/util/fst/FSTTester.java:420-520 (at 5adff78bb168)
Suggested fix
  • lucene/codecs/src/test/org/apache/lucene/codecs/simpletext/TestSimpleTextPostingsFormat.java:24 (at 5adff78bb168) (@Nightly); lucene/core/src/java/org/apache/lucene/util/fst/FSTEnum.java:166, :196, :362, :393, :507, :529; FST.java:1047, :1068, :1085; Util.java:917
Prior reports
  • https://github.com/apache/lucene/pull/14477, https://github.com/apache/lucene/pull/16095, https://github.com/apache/lucene/pull/12688, https://github.com/apache/lucene/issues/3917, https://github.com/apache/lucene/issues/6094
Diagrams
  • lucene/core/src/java/org/apache/lucene/util/fst/FSTEnum.java:79-89; (at 5adff78bb168) IntsRefFSTEnum.java:91-97

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

Repro files

About the diagrams

The diagram illustrates the repro's FST ([3] -> 1, [3, MAX] -> 2), the reused enum on [3, MAX], the comparison at position 2 (MAX against END_LABEL -1, subtraction versus Integer.compare) and the two outcomes.

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.