All findings · Lucene finding · LFV-1159 · readceilarc-follow-overwritten-when-same-arc

Util.readCeilArc returns a wrong arc or null when follow and arc are the same Arc object

With one Arc object passed as both follow and arc, Util.readCeilArc overwrites follow mid-call and, on a list-encoded node, searches one level too deep. For the one-key FST ab, the ceiling of 'a' from the start arc returns b instead of a, with no error; Lucene's callers pass two arcs.

Summary

Priority
Low, rank 915Util.readCeilArc silently returns a wrong arc or null when the caller passes one Arc object as both follow and arc and the target node is list-encoded, and throws when follow is a final arc, in every release since 4.1.0. Only application code that calls this expert, experimental FST helper in that in-place style is hit, and no Lucene caller does, so it is rare and Low.
Impact
silent wrong results
Since
4.1.0 (LUCENE-3846, 2012)introduced 2012-10-11 · over 14 years
Affected
4.1.0 to 10.6.0 and main (10 releases run, all fail); not fixed upstream
Status
documented
Found by
semi-formal: a proof-obligation audit of Util.readCeilArc's 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 arcs and readCeilArc

  1. An FST is a graph: each node has outgoing arcs, and each arc has a label (here a byte) and points to a target node. An FST.Arc object describes one arc; the FST's read methods fill in an Arc object you pass to them.
  2. Util.readCeilArc(label, fst, follow, arc, in) looks at the arcs leaving follow and reads the first one whose label is >= label into arc. The sibling FST.findTargetArc(label, follow, arc, in) is routinely called with one object for both, findTargetArc(label, arc, arc, in), to step down the FST in place.
  3. FSTCompiler stores a small node as a plain list of arcs (scanned one by one) and a node with many arcs as a fixed-length array (binary search, direct addressing or a continuous range). The compiler, not the caller, picks which; it shows only through low-level accessors such as Arc.bytesPerArc().
Diagram of the bug: the FST for the key ab has a start node with one arc a, leading to a node with one arc b. With two Arc objects, follow stays on the start arc and the scan of the start node returns a. With one shared Arc object, readFirstTargetArc first writes the arc a into it, so follow.target() now points to the node after a, and the scan there returns b.
One Arc object plays both roles. Step 1 overwrites it with the start node's first arc, a. Step 2 restarts the list scan at its target, which is now the node below a, so the ceiling of 'a' comes back as b. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. Build an FST with the single key ab using the default FSTCompiler. The start arc leads to a node with one arc, a, and a leads to a node with one arc, b. Both nodes are small, so the compiler stores their arcs as a plain list.
  2. Call Util.readCeilArc('a', fst, arc, arc, in) with the start arc as both follow and arc: the in-place style of FST.findTargetArc and of Lucene's own TestUtil.
  3. You expect the arc a (label 97), the first arc >= 'a' leaving the start arc. Two separate Arc objects return exactly that.
  4. Instead you get b (label 98), an arc of the next node down, with no error and with assertions on or off. With keys ab, ac, x, the ceiling of 'w' is null instead of x; if the start arc is final (the empty key is in the FST), or the root's first arc ends a key that nothing continues past (the one-key FST x), the call throws ArrayIndexOutOfBoundsException: Index -1.
  5. Why: the method first reads the start node's first arc, a, into arc, which is also follow. For a list node it then restarts its scan at follow.target(), which now points to the node below a.

Who is affected

Only application code that calls the public, @lucene.experimental Util.readCeilArc itself and passes one Arc object as both arguments, on a list-encoded node or a final arc: default compiler settings, any small node. Lucene's own callers, the codecs' FSTTermsReader and the suggesters' FSTUtil, pass two arcs and are unaffected. Every release from 4.1.0 to 10.6.0.

Mechanism, terms and numbers693 words

Util.readCeilArc(label, fst, follow, arc, in) reads "the first arc greater or equal than the given label into the provided arc in place" (Util.java:810 at 5adff78bb168) from the arcs leaving follow. It first calls fst.readFirstTargetArc(follow, arc, in) (:827), which writes the first arc of follow's target node into arc. When follow and arc are the same object, follow now describes that child arc. The fixed-length branches (:828-871) read only arc's node header and still answer correctly, but a list-encoded node falls through to fst.readFirstRealTargetArc(follow.target(), arc, in) (:875), and follow.target() is by then the child's target, so the scan runs over the node one level down. For the one-key FST ab, the aliased call on the start arc returns b instead of a, silently, with assertions on and off; with keys ab, ac, x the ceiling of 'w' is null instead of x; with a final start arc, or (keys x) when the root's first arc has no outgoing arcs, the call reads FST address -1 and throws ArrayIndexOutOfBoundsException. The two lines date from the method's first version (LUCENE-3846, released in 4.1.0). No Lucene production code passes the same object twice; the two test calls that do pass leaf arcs, where the method returns early.

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

  1. Util.java:824-826: if follow has no target arcs, return null. Leaf arcs stop here.
  2. Util.java:827: fst.readFirstTargetArc(follow, arc, in) writes the first arc of follow's target into arc (FST.java:687-692, "this changes the provided arc (2nd arg) in-place"). With follow == arc, follow is now that child arc. readFirstTargetArc itself reads each field of follow before it overwrites that field, so it is alias-safe.
  3. A list-encoded node gets bytesPerArc = 0 (FST.java:738-741), so the if at Util.java:828 is skipped. The fixed-length branches inside it (direct addressing, continuous, binary search) read only arc's node header and are unaffected.
  4. Util.java:875 (the fix site): fst.readFirstRealTargetArc(follow.target(), arc, in). follow.target() is now the child's target, so the linear scan (:877-887) runs over the node one level down and returns its first arc >= label, or null. If that child arc has no outgoing arcs (it ends a key and nothing continues past it, so its target() is FINAL_END_NODE, -1), :875 reads address -1 and throws as in step 5, although the start arc is not final.
  5. Final follow: readFirstTargetArc turns the shared object into the virtual END arc with target = FINAL_END_NODE (FST.java:697-708; FINAL_END_NODE = -1, FST.java:132). The END label skips the if at :828, and :875 reads address -1: ArrayIndexOutOfBoundsException: Index -1 out of bounds for length 32768 at ReverseBytesReader.readByte:30 <- FST.readFirstArcInfo:722 <- FST.readFirstRealTargetArc:746 <- Util.readCeilArc:875 (executed on an on-heap FST with a list-encoded root, and also with a continuous and a binary-search root: a final follow throws whatever the encoding).

The numbers

  • Keys ab (the repro): aliased ceiling of 'a' on the start arc is b (98) instead of a (97).
  • Keys ab, ac, x (root list-encoded, bytesPerArc 0): distinct arcs give a for 'a' and x for 'w'; aliased gives b and null.
  • Keys "", ab (final start arc): distinct arcs give a; aliased throws the index -1 exception.
  • Keys x (start arc not final; the root's one arc x ends the key, target() -1): distinct arcs give x for 'a'; aliased throws the same index -1 exception. Keys a, b: the same.
  • Keys ab, ac, b, c, d, e (5 root arcs): with default settings the root is fixed-length (bytesPerArc 2) and the aliased call gives a; with allowFixedLengthArcs(false) it is a list (bytesPerArc 0) and gives b. (The keys above give a list root with either setting.)

When a node is list-encoded: FSTCompiler uses fixed-length arcs only for nodes with >= 5 arcs at depth <= 3 or >= 10 arcs anywhere (FSTCompiler.java:77-87, :601-606), so with default settings most small nodes are lists.

Exposure and likelihood218 words
  • Who. Application code that calls Util.readCeilArc directly and passes one Arc object as both follow and arc on a non-leaf arc whose target node is list-encoded, or that is final. Default FSTCompiler settings, tiny input (one key in the repro); no scale or configuration needed.
  • Not affected. Lucene's two non-test callers pass distinct objects: FSTTermsReader.java:675 (lucene/codecs, top.fstArc and frame.fstArc) and FSTUtil.java:105 (lucene/suggest, path.fstNode and scratchArc). The downstream callers found by code search (for example Apache Pinot's RegexpMatcher) also pass distinct arcs.
  • Effect. A wrong arc, taken from the wrong level, or null, silently: a walk built on it takes a wrong path or stops early. Or an ArrayIndexOutOfBoundsException (index -1) when follow is final or when the first arc of its target node has no outgoing arcs (for example the one-key FST x). Same with assertions on and off.
  • Opt-in part. Util is marked @lucene.experimental (Util.java:38-42); the trigger is the caller's own aliasing.
  • Likelihood: rare. No caller found does it and no prior report was found, but whoever does it gets a wrong answer for most labels on a list-encoded node: for the one-key FST ab, 67 of the 95 printable ASCII labels (the scan of the wrong node gives the right answer only by coincidence).

Minimal repro

One JUnit test method on the public API: a one-key FST ab, one aliased readCeilArc call, one assertion. Deterministic and single-threaded; the test itself takes about a second; it fails with assertions on and off.

TestReadCeilArcSameFollowAndArc.java (lucene/core, package org.apache.lucene.util.fst, 1 test)18 lines
package org.apache.lucene.util.fst;

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

public class TestReadCeilArcSameFollowAndArc extends LuceneTestCase {

  public void testSameFollowAndArc() throws Exception {
    FSTCompiler<Object> compiler =
        new FSTCompiler.Builder<>(FST.INPUT_TYPE.BYTE1, NoOutputs.getSingleton()).build();
    compiler.add(Util.toIntsRef(new BytesRef("ab"), new IntsRefBuilder()), NoOutputs.getSingleton().getNoOutput());
    FST<Object> fst = FST.fromFSTReader(compiler.compile(), compiler.getFSTReader());
    FST.Arc<Object> arc = fst.getFirstArc(new FST.Arc<>());
    // expected: the root's arc 'a' (as with two distinct Arc objects); actual: the child's arc 'b'
    assertEquals('a', Util.readCeilArc('a', fst, arc, arc, fst.getBytesReader()).label());
  }
}
Command
cp $REPRO/TestReadCeilArcSameFollowAndArc.java $LUCENE/lucene/core/src/test/org/apache/lucene/util/fst/
cd $LUCENE && ./gradlew :lucene:core:test --tests org.apache.lucene.util.fst.TestReadCeilArcSameFollowAndArc -Ptests.seed=DEADBEEF
# and with -Ptests.asserts=false: the same failure
Expected
The arc a (label 97), as with two distinct Arc objects.
Observed
The arc b (label 98): java.lang.AssertionError: expected:<97> but was:<98>.

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

More files, environment, full output156 words

TestReadCeilArcSameFollowAndArc (one method, extends LuceneTestCase, package org.apache.lucene.util.fst of the lucene/core tests). It builds the FST with FSTCompiler.Builder(BYTE1, NoOutputs) and default settings, takes the start arc and asserts that Util.readCeilArc('a', fst, arc, arc, in) returns label 'a'.

As the regression test, the same method fits in TestUtil. Line 12 is longer than 100 characters, which ./gradlew tidy reformats.

Environment

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

Observed output

TestReadCeilArcSameFollowAndArc > testSameFollowAndArc FAILED
    java.lang.AssertionError: expected:<97> but was:<98>
        at __randomizedtesting.SeedInfo.seed([DEADBEEF:E0B3699405501700]:0)
        at org.junit.Assert.fail(Assert.java:89)
        at org.junit.Assert.failNotEquals(Assert.java:835)
        at org.junit.Assert.assertEquals(Assert.java:647)
        at org.junit.Assert.assertEquals(Assert.java:633)
        at org.apache.lucene.util.fst.TestReadCeilArcSameFollowAndArc.testSameFollowAndArc(TestReadCeilArcSameFollowAndArc.java:16)

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

Why it looks unintended

  • The javadoc reads the ceiling arc "into the provided arc in place" and never says follow and arc must differ.
  • The in-place siblings are alias-safe: Util.get calls findTargetArc(label, arc, arc, in), and Lucene's own TestUtil calls readCeilArc(label, fst, arc, arc, in).
  • Same keys: right with a fixed-length root, wrong with a list root; the compiler, not the caller, picks which.
The full argument190 words
  • Contract. Util.java:809-818: "Reads the first arc greater or equal than the given label into the provided arc in place and returns it iff found"; @param follow "the arc to follow reading the label from", @param arc "the arc to read into in place". No javadoc, package doc, CHANGES or MIGRATE entry requires distinct objects.
  • Siblings. FST.findTargetArc "Finds an arc leaving the incoming arc, replacing the arc in place" (FST.java:1006-1008); Util.get calls fst.findTargetArc(label, arc, arc, fstReader) (Util.java:57, :84); FST.readFirstTargetArc "changes the provided arc (2nd arg) in-place" (FST.java:687-692). Both are alias-safe; the devil's-advocate verifier counted 18 non-test call sites in main that pass one arc twice to them.
  • Tests. TestUtil.java:63 and :101 call Util.readCeilArc(label, fst, arc, arc, in), but only on leaf arcs, where the early return at Util.java:824-826 hides the defect: the call shape Lucene's own test uses, not proof of a tested contract.
  • History. Both lines are in the method's first version (LUCENE-3846, 2012); the only later change to either is the rename follow.target -> follow.target() (760f2dbdcb2, LUCENE-8920). No commit, issue or PR found in the searches discusses aliasing.
The devil's-advocate check128 words

The strongest case for "intended": the method has two separately named parameters with different roles, Util is @lucene.experimental, no production caller aliases them, and TestUtil's aliased calls only reach the leaf early return; so the rule is "pass two arcs".

Why it fails: that rule is written nowhere; the same-object idiom is established by in-tree use of the sibling methods with the same (follow, arc) pair (18 call sites) and safe for them; Lucene's own test uses the call shape; and the aliased call is right or wrong depending only on the node encoding. 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 may still prefer to document the rule (fix B).

Suggested fix

Recommended Save follow's target before it can be overwritten (fix A)

Read follow.target() into a local before readFirstTargetArc can overwrite it, and start the list scan from that local (2 lines in Util.readCeilArc). It also fixes both exception cases. The repro passes with assertions on and off, and 98 tests of the closest suites pass.

    // follow and arc may be the same object: read follow's target before arc is overwritten
    final long followTarget = follow.target();
    fst.readFirstTargetArc(follow, arc, in);
    ...
    fst.readFirstRealTargetArc(followTarget, arc, in);
All options and trade-offs2 options ยท 355 words

Fix A was built and checked through gradle on 5adff78bb168; B changes only the javadoc and was not run. Open PR #12688 adds a PrimitiveLongFST copy of readCeilArc with the same call order, so the same change applies there.

Save follow's target before it can be overwritten (fix A) (validated by the checks) (recommended)

In Util.readCeilArc, store follow.target() in a local before fst.readFirstTargetArc(follow, arc, in) (Util.java:827) and pass that local to readFirstRealTargetArc at :875.

    // follow and arc may be the same object: read follow's target before arc is overwritten
    final long followTarget = follow.target();
    fst.readFirstTargetArc(follow, arc, in);
    ...
    fst.readFirstRealTargetArc(followTarget, arc, in);

Trade-offs

One local long. Callers with distinct arcs read the same node as before. It also fixes both exception cases (a final follow, and a first child arc with no outgoing arcs): the local is the real node (targetHasArcs checked it is > 0), so the scan reads the real arcs instead of address -1 (executed: every one-object answer equals the two-object answer).

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 repro (1), TestUtil (5), TestFSTs (22), TestFSTDirectAddressing (3), TestFuzzySuggester (14), TestAnalyzingSuggester (23) and TestFSTPostingsFormat (30, lucene/codecs): 98 tests, 0 failures, 5 skipped, assertions on. checkGoogleJavaFormat on core passes. 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 change pass the repro (assertions on and off), the finder's probe and TestUtil; the cross-model reviewer's own test passes with it.

Document that follow and arc must be distinct objects (fix B) (not run)

Add the rule to the readCeilArc javadoc and, for consistency, change the two aliased TestUtil calls (:63, :101), which pass today only because they hit the leaf early return.

Trade-offs

No runtime change, but anyone who uses the in-place idiom of the sibling methods still gets a silent wrong answer.

What the checks validated

Not run.

Downstream note

Downstream code that walks an FST with Util.readCeilArc and reuses one Arc object would silently follow wrong paths; the downstream callers that were checked pass two arcs.

How it was found

A proof-obligation audit of Util.readCeilArc's contract: the ceiling arc must be the same whatever object it is read into, including the in-place call with one Arc for both arguments. The obligation fails at the second read of follow.target(), after follow was overwritten; a public-API JUnit test then witnessed the wrong arc.

The method in full129 words

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

  • The finder checked readCeilArc against the obligation that its result does not depend on whether follow and arc are distinct, the in-place idiom of findTargetArc, Util.get and TestUtil, and found that :875 reads follow.target() after :827 may have overwritten follow.
  • The finder then wrote a public-API JUnit test (keys ab, ac, x: b instead of a), a probe for the null and index -1 cases, and the 2-line fix that makes them pass; the test was later reduced to the one-key FST ab.
  • An independent reproducer, an executed cross-model review, a devil's-advocate verifier and an adjudication reproduced it with their own tests ("Prior reports, history and checks").

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 (searched 2026-11-03). Nearest: #9825 (LUCENE-8781), a different readCeilArc defect fixed in 8.2; open PR #12688 copies the same call order without mentioning it.

Searches3 searches

No matching public report was found in the documented searches. Nearest items, none a report of this bug: #9825 (LUCENE-8781: readCeilArc lacked a direct-addressing branch, fixed in 8.2: badcc4e6c72 on branch_8x, 2e49f13aa1e on main), lucene-solr #800 (LUCENE-8920: added TestUtil with the aliased leaf-arc call; fixed a different off-by-one before merge), #12748 (continuous arcs; a second aliased leaf-arc call), #4919 (LUCENE-3846, which added the method) and open PR #12688 (adds a PrimitiveLongFST copy of readCeilArc with the same sequence). A last check read the issues and PRs updated since the previous check (none about this, none touching util/fst). 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-03two scripted prior-report searches (104 queries, 290 distinct hits in the second) and about 40 manual searches: method and class names, aliasing, in-place arcs, symptoms; the JIRA REST searchno report; #9825 is the only readCeilArc defect on record
Open and recent PRs2026-11-03 (twice)the changed files of all 409 open apache/lucene PRs, of the 40 PRs updated since 2026-11-01, and of the 4 PRs updated since a recent cutoffonly #12688 touches Util.java; it copies the sequence and does not report it
Mailing lists, downstream, web2026-11-03dev@, java-user@, issues@, commits@ and general@; apache/lucene-solr, Solr, Elasticsearch, OpenSearch and Pinot trackers; GitHub code search; web queriesnothing about this bug

History

Present since the method was added by LUCENE-3846 (66b2c78d6ba, 2012) and first released in 4.1.0; 4.0.0 has no such method. Every release run since then, through 10.6.0 and main, fails. Not fixed upstream.

Versions tested5 rows

Introduced with the method by LUCENE-3846 "Fuzzy suggester" (Michael McCandless; now #4919): 66b2c78d6ba on the feature branch, 2012-10-11; reached trunk as d8e44bd09d6 and branch_4x as 1fb15d729d4 on 2012-10-30 (Subversion era, no pull request). The first version already has readFirstTargetArc(follow, arc, in) followed by readFirstRealTargetArc(follow.target, arc, in). git blame credits line 875 to the later rename 760f2dbdcb2 (LUCENE-8920).

Releases before 9.x ran main() versions of the same aliased call with the older FST API (keys ab, ac, x); 9.x and later ran the JUnit test. By reading, no commit changed the two lines in 9.0.0 to 9.11.x or 10.1.0 to 10.5.1. Upstream main 1c2072f0284 (2026-11-02) still has both lines.

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.0.0, 10.6.0 affectedthe hunt's JUnit test (keys ab, ac, x): b instead of a
10.5.2 independent testkeys cat, cow, dog: o instead of c
4.1.0, 4.10.4, 5.5.5, 6.6.6, 7.7.3, 8.11.4 older-API formb instead of a; distinct arcs give a
4.0.0 API absentcompile error: no Util.readCeilArc

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 verdicts255 words

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

Each independent check and its verdict
CheckVerdictWhat it found
finder reproducedKeys ab, ac, x: b instead of a on main (assertions on and off), 10.6.0 and 9.12.3; probe: list-encoded root, null instead of x, index -1 exception; the 2-line fix passes.
independent reproducer realOwn test (keys cat, cow, dog: o instead of c) fails on main (on, off), 10.6.0, 10.5.2 and 9.12.3; the distinct-arc control passes; its own 2-line fix passes.
cross-model review, refute lens (OpenAI GPT-5.6, executed) real (high)Own 3-case test fails 2 (null instead of c; index -1 exception); the fixed-length-root control passes; the fix passes it and TestUtil.
devil's-advocate verifier realNo documented distinct-object rule; the siblings are alias-safe and called aliased at 18 sites; the same keys are right with fixed-length arcs and wrong with list arcs.
adjudication, separate re-runs 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 (one-key FST ab).
this doc reproduced; fix A validatedGradle without the fix fails (on, off); with it the repro and 97 more tests of the closest suites pass.

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

Finding record

LFV-1159, snapshot of 2026-11-06

Registry title
Util.readCeilArc returns a wrong arc or null, with no error, when one Arc object is passed as both follow and arc and the target node is list-encoded, and reads FST address -1 when follow is final (since 4.1.0)
Area
util / FST (Util.readCeilArc: follow overwritten by readFirstTargetArc before the list-node scan re-reads follow.target())
Priority
Low, rank 915: Util.readCeilArc silently returns a wrong arc or null when the caller passes one Arc object as both follow and arc and the target node is list-encoded, and throws when follow is a final arc, in every release since 4.1.0. Only application code that calls this expert, experimental FST helper in that in-place style is hit, and no Lucene caller does, so it is rare and Low.
Impact class
silent wrong results
Impact note
Only application code that calls the public, @lucene.experimental Util.readCeilArc directly and passes one Arc object as both follow and arc (the in-place style of FST.findTargetArc, Util.get and Lucene's own TestUtil) is hit: when the target node is list-encoded, which FSTCompiler picks for nodes with few arcs, it silently returns a wrong arc or null, and when follow is a final arc it throws ArrayIndexOutOfBoundsException, with -ea and -da; tiny input, default FSTCompiler settings, no scale needed; no Lucene code path is affected (FSTTermsReader and the suggest FSTUtil pass distinct arcs); every release since 4.1.0 and main.
Summary
You build a small FST (keys ab, ac, x) with the default FSTCompiler and call Util.readCeilArc('a', fst, arc, arc, in) with the start arc as both follow and arc, the same call shape Lucene's own TestUtil uses. You expect the arc 'a', the first arc >= 'a' leaving the start arc, which the same call with two distinct Arc objects returns. Instead it returns the arc 'b' from the node one level down (and null instead of 'x' for label 'w'), because readFirstTargetArc overwrites the shared object before the list-node scan re-reads follow.target(); when follow is a final arc the call reads FST address -1 and throws ArrayIndexOutOfBoundsException.
Introduced
66b2c78d6bafbed21e70ab1de9fea25f35f2ca04 (LUCENE-3846 'Fuzzy suggester', now #4919, mikemccand; svn feature branch lucene3846 r1397171; no pull request, Subversion era), which reached trunk as d8e44bd09d6 (r1403779) and branch_4x as 1fb15d729d4 (r1403780) [read: git log -L on readCeilArc in the history clone; the method's first version already has readFirstTargetArc(follow, arc, in) followed by readFirstRealTargetArc(follow.target, arc, in), and the only later change to either line is the follow.target -> follow.target() rename in 760f2dbdcb2 (LUCENE-8920); exec: 4.0.0 has no Util.readCeilArc (compile error), 4.1.0 fails; the commits themselves were not built (Subversion-era builds)] (2012-10-11 (author and commit date of 66b2c78d6ba on the feature branch); trunk and branch_4x commits 2012-10-30)
First affected version
4.1.0 [exec: 4.1.0 fails with -da, the aliased call gives 'b' instead of 'a' while distinct arcs give 'a'; 4.0.0 has no Util.readCeilArc (compile error), so the defect is as old as the API]
Affected versions
every release from 4.1.0 through 10.6.0 (released 2026-10-29, 4ae946e5a9b) and main 5adff78bb168 (12.0.0-SNAPSHOT); not fixed anywhere (upstream main 1c2072f0284, 2026-11-02, still has both lines at Util.java:827 and :875). Executed, fail [exec]: main -ea and -da (finder, the independent reproduction, the cross-model review, the intended-behaviour check, judge and the registration check), 10.6.0 (-ea and -da), 10.5.2 (-ea, the independent reproduction), 10.0.0 (-da), 9.12.3 (-ea and -da), 8.11.4, 7.7.3, 6.6.6, 5.5.5, 4.10.4 and 4.1.0 (-da; releases before 9.x through main() adapters with the same FST {ab, ac, x} and the same aliased call); 4.0.0: compile error (API absent). 9.0.0-9.11.x and 10.1.0-10.5.1 by reading (no commit changed the two lines). Not new in 10.6. 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-03, on top of the finder's, the independent reproduction's, the intended-behaviour check's and the adjudication's searches; rechecked the same day): two scripted prior-report searches (104 queries over apache/lucene issues, PRs and commits, downstream repositories, the dev@, java-user@, issues@ and commits@ lists and the JIRA archive; 290 distinct hits in the second run, none high), about 40 manual GitHub searches incl. apache/lucene-solr, Elasticsearch, OpenSearch, Solr and Pinot, GitHub code search, web searches, the changed files of all 409 open apache/lucene PRs and of the 40 PRs updated since 2026-11-01 (none touches util/fst), full reads of #9825 (body and all 25 comments) and lucene-solr PRs #721 and #800, the Util.java patch of open PR #12688, and scans of a dozen related issues (#4919, #6216, #6646, #9963, #12895 and others). The only readCeilArc defect on record is #9825 (LUCENE-8781, the missing direct-addressing branch, fixed in 8.2 by 2e49f13aa1e), a different root cause; lucene-solr PR #800 fixed a different off-by-one before merge. Open PR #12688 (random-access term dictionary) adds a PrimitiveLongFST copy of readCeilArc with the same sequence and neither reports nor fixes it. Not fixed anywhere: upstream main 1c2072f0284 still has both lines.
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; 98 tests of the closest suites with fix A: pass) and the hunt's runs; release jars 4.1.0, 4.10.4, 5.5.5, 6.6.6, 7.7.3, 8.11.4, 9.12.3, 10.0.0, 10.5.2 and 10.6.0 (all fail) and 4.0.0 (no such method). The cross-model review ran on ea57360912ed, whose Util.java and FST.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/Util.java:38-42 (at 5adff78bb168) (@lucene.experimental), :809-818 (readCeilArc javadoc: 'Reads the first arc greater or equal than the given label into the provided arc in place'; @param follow 'the arc to follow reading the label from', @param arc 'the arc to read into in place'), :824-826 (targetHasArcs early return), :827 (fst.readFirstTargetArc(follow, arc, in)), :828-871 (array, direct-addressing and continuous branches read only arc's node fields), :873-887 (list scan; :875 fst.readFirstRealTargetArc(follow.target(), arc, in))
  • lucene/core/src/java/org/apache/lucene/util/fst/FST.java:132 (at 5adff78bb168) (FINAL_END_NODE = -1), :687-716 (readFirstTargetArc 'changes the provided arc (2nd arg) in-place'; a final follow becomes the virtual END arc with target FINAL_END_NODE at :708), :1006-1010 (findTargetArc 'replacing the arc in place'); lucene/core/src/java/org/apache/lucene/util/fst/FSTCompiler.java:77-87 and :601-606 (fixed-length arcs only for nodes with >= 5 arcs at depth <= 3 or >= 10 arcs; other nodes are list-encoded)
  • lucene/core/src/test/org/apache/lucene/util/fst/TestUtil.java:63 (at 5adff78bb168) and :101 (Util.readCeilArc(label, fst, arc, arc, in) on leaf arcs); lucene/codecs/src/java/org/apache/lucene/codecs/memory/FSTTermsReader.java:675 and lucene/suggest/src/java/org/apache/lucene/search/suggest/analyzing/FSTUtil.java:105 (in-tree callers, distinct arcs)
The finding in 30 seconds
  • lucene/core/src/java/org/apache/lucene/util/fst/Util.java:809-818, (at 5adff78bb168) :824-828, :875-887
  • lucene/core/src/java/org/apache/lucene/util/fst/FST.java:132, (at 5adff78bb168) :687-716, :738-741
  • lucene/core/src/java/org/apache/lucene/util/fst/FSTCompiler.java:77-87, (at 5adff78bb168) :601-606
Who is affected
  • lucene/codecs/src/java/org/apache/lucene/codecs/memory/FSTTermsReader.java:675 (at 5adff78bb168)
  • lucene/suggest/src/java/org/apache/lucene/search/suggest/analyzing/FSTUtil.java:105 (at 5adff78bb168)
  • lucene/core/src/java/org/apache/lucene/util/fst/Util.java:38-42 (at 5adff78bb168) (@lucene.experimental)
Why it looks unintended
  • lucene/core/src/java/org/apache/lucene/util/fst/Util.java:57, (at 5adff78bb168) :84, :809-818, :824-826
  • lucene/core/src/java/org/apache/lucene/util/fst/FST.java:687-692, (at 5adff78bb168) :1006-1008
  • lucene/core/src/test/org/apache/lucene/util/fst/TestUtil.java:63, (at 5adff78bb168) :101
Prior reports
  • https://github.com/apache/lucene/issues/9825, https://github.com/apache/lucene-solr/pull/800, https://github.com/apache/lucene/pull/12688, https://github.com/apache/lucene/pull/12748
Diagrams
  • lucene/core/src/java/org/apache/lucene/util/fst/Util.java:827, (at 5adff78bb168) :875

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 (key ab) and the shared object before and after Util.java:827, and where :875 starts the scan.

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.