A 64-bit set needs one 64-bit word. Back it with one word or with two, and equals gives different answers.
- A
FixedBitSetof n bits keeps them in along[], 64 bits per word. It needsnumWords= n / 64 rounded up: 64 bits need 1 word, 100 bits need 2. new FixedBitSet(n)allocates exactlynumWordswords.new FixedBitSet(long[] storedBits, n)uses your array as it is; it may be longer, and the words pastnumWordsmust be 0.LongBitSetworks the same way.
long[1], one by long[2] with a clear extra word. hashCode reads only the one word in use and agrees; equals compares whole arrays, sees lengths 1 and 2, and says false. Comparing only the words in use says true. Select the still to open it at full size. Generated with AI, checked against the code.- Build two empty 64-bit sets:
new FixedBitSet(64), backed by exactly one word (long[1]), andnew FixedBitSet(new long[2], 64), backed by two. The constructor's javadoc says the array "may be larger" as long as the extra bits are clear; here they are all 0. - Both have
length()64,cardinality()0 and the samehashCode(), 0x98761234. You expectequalsto return true. - It returns false, with assertions on and off.
equalschecksnumBits, then callsArrays.equals(bits, other.bits), which first compares the array lengths: 1 vs 2.hashCode,cardinality, iteration and the bulk operations read only the words in use. - So a
HashSetholding one set misses a lookup with the other, and a clone of an oversized set is unequal to an exact-size copy.LongBitSet.equals, documented as "returns true if both sets have the same bits set", fails the same way.
Only code that compares or hashes FixedBitSet/LongBitSet instances where one was built over a larger array (a reused scratch long[], or a clone of such a set). The only non-test bit-set equals call found, in RunAutomaton, uses exact-size sets, so no index or search result is known to change. False negatives only, at any size.
Mechanism, terms and numbers441 words
FixedBitSet(long[] storedBits, int numBits) and LongBitSet(long[] storedBits, long numBits) accept a backing array that is larger than needed, as long as the extra words are clear. Each set remembers numWords, the number of words it actually uses, and hashCode, cardinality, iteration and the bulk operations all stop there. equals does not: after checking numBits it calls Arrays.equals(bits, other.bits), which also compares the two array lengths. So two sets with the same length and the same bits, one over long[1] and one over long[2], have equal hash codes but are not equal; a hash lookup with one misses the other, and a clone of an oversized set is unequal to an exact-size copy. It has been so since 4.0.0. The only non-test call of a bit-set equals found (RunAutomaton) uses exact-size sets, so no index or search result is known to be affected; the fix is one line per class: compare only the first numWords words.
FixedBitSet.java:146-150:FixedBitSet(int numBits)allocates exactlybits2words(numBits)words, sonumWords == bits.length.FixedBitSet.java:152-170:FixedBitSet(long[] storedBits, int numBits)setsnumWords = bits2words(numBits), rejects only an array that is too small, and keeps the array as it is, sobits.lengthmay exceednumWords. Its javadoc: "The storedBits array must be large enough to accommodate the numBits specified, but may be larger. In that case the 'extra' or 'ghost' bits must be clear". With assertions on it checks that (verifyGhostBitsClear,:183-193).FixedBitSet.java:833-846,equals: returns false ifnumBitsdiffer, elsereturn Arrays.equals(bits, other.bits);(:845).java.util.Arrays.equals(long[], long[])is false whenever the two lengths differ.FixedBitSet.java:848-859,hashCode:for (int i = numWords; --i >= 0; ). EqualnumBitsmeans equalnumWords, so two sets with the same bits hash the same whatever their capacity.cardinality,nextSetBit,or/and/andNotand the static count helpers also usenumWords.FixedBitSet.java:826-830,clone(): copies intonew long[this.bits.length], so a clone keeps the extra capacity.LongBitSet.java:85-103,:427-441(line 440 is the sameArrays.equals),:443-454,:420-424: identical.- Not a trigger:
ensureCapacity(FixedBitSet.java:81,LongBitSet.java:55) builds the new set withnumBits = arr.length << 6, so its array is exact.
new FixedBitSet(64) and new FixedBitSet(new long[2], 64): length 64/64, cardinality 0/0, hashCode -1737092556/-1737092556 (0x98761234), equals false/false (both directions). The study's variant with bits {0, 7, 63, 64, 99} in 100-bit sets over long[2] vs long[5]: cardinality 5/5, hashCode equal, equals false; HashSet.contains with the roomy set returns false; the same for LongBitSet.
Exposure and likelihood262 words
- Who is hit. Code that calls
equalson, or hashes, these sets when at least one was built with the two-argument constructor over a larger array, or is a clone of such a set, and the other was not. It gets "not equal" for equal sets;HashSet/HashMaplookups miss. - No affected Lucene code path found. A bytecode scan of the built Lucene jars finds one direct call of a bit-set
equalsorhashCodein non-test code,RunAutomaton.equals(RunAutomaton.java:200), whoseacceptset isnew FixedBitSet(size)(:69), always exact. Tests also compare bit sets (TestFixedBitSet.java:274-284,TestLongBitSet.java:201-211,TestScorerPerf.java:131,TestBooleanQuery.java:461), all built with the exact-size constructor. The scan cannot see calls throughObject.equals, such as a set used as a collection key; a source search found no map or set keyed by a bit set (as Michael McCandless wrote on LUCENE-3531, crediting Uwe Schindler, FixedBitSet'sequals/hashCodeare "horribly costly" as cache keys), and downstream code was not checked. Lucene does build oversized sets (bkd/DocIdsWriter.java:276-284, over a reused scratch array) but only iterates them. - Default vs opt-in. No index, query or codec setting reaches it; only direct use of these public
@lucene.internalclasses. - Scale. None: any size, from 0 bits up; one spare word in the backing array is enough (executed for 0, 1, 63, 64, 65 and 1000 bits, both classes,
-eaand-da). - Severity. A wrong boolean, and only ever "not equal": it never makes two different sets equal. No crash and no wrong search results were found.
One JUnit test, one assertion, public API only: assertEquals(new FixedBitSet(64), new FixedBitSet(new long[2], 64)). Deterministic, single thread; fails with assertions on and off.
/*
* 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 TestFixedBitSetEqualsBackingCapacity extends LuceneTestCase {
public void testEmptySetsWithDifferentBackingArraysAreEqual() {
// expected: equal (both 64 bits, none set); actual: unequal (backing long[1] vs long[2])
assertEquals(
"64 bits, none set, backing long[1] vs long[2]",
new FixedBitSet(64),
new FixedBitSet(new long[2], 64));
}
}
cd $LUCENE && ./gradlew :lucene:core:test --tests org.apache.lucene.util.TestFixedBitSetEqualsBackingCapacity -Ptests.seed=DEADBEEF -Ptests.asserts=true
- Expected
equalsis true: both sets have 64 bits, none set, and the samehashCode.- Observed
AssertionError: 64 bits, none set, backing long[1] vs long[2] expected: ...FixedBitSet@98761234 but was: ...FixedBitSet@98761234, with assertions on and off.
More files, environment, full output251 words
One test class, one method, nothing random (the seed only satisfies LuceneTestCase). Two 64-bit sets with no bits set, backed by long[1] and long[2]; the extra word is 0, as the constructor requires, so the run passes verifyGhostBitsClear with assertions on. LongBitSet has the identical line and fails the same way (shown by the tie-break and study runs, not by this test).
Both sides print as FixedBitSet@98761234: that is Object.toString, which shows the (equal) hashCode.
Regression test: this same test. It fails on stock main and passes with the fix.
| lucene | main 645d4fe14a20 (11.0.0-SNAPSHOT): fails; 10.6.0, 10.5.2, 10.0.0, 9.12.3: fail; with the fix: passes |
|---|---|
| jdk | OpenJDK 25.0.3 |
| os | Linux aarch64 (the only platform run) |
cp $REPRO/TestFixedBitSetEqualsBackingCapacity.java $LUCENE/lucene/core/src/test/org/apache/lucene/util/
cd $LUCENE && ./gradlew :lucene:core:test --tests org.apache.lucene.util.TestFixedBitSetEqualsBackingCapacity -Ptests.seed=DEADBEEF -Ptests.asserts=true
# same failure with -Ptests.asserts=false
# gradle, main 645d4fe14a20, seed DEADBEEF, -Ptests.asserts=true (excerpt; ... marks left-out lines)
TestFixedBitSetEqualsBackingCapacity > testEmptySetsWithDifferentBackingArraysAreEqual FAILED
java.lang.AssertionError: 64 bits, none set, backing long[1] vs long[2] expected: org.apache.lucene.util.FixedBitSet<org.apache.lucene.util.FixedBitSet@98761234> but was: org.apache.lucene.util.FixedBitSet<org.apache.lucene.util.FixedBitSet@98761234>
at __randomizedtesting.SeedInfo.seed([DEADBEEF:A8A7AF7D109AA018]: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.apache.lucene.util.TestFixedBitSetEqualsBackingCapacity.testEmptySetsWithDifferentBackingArraysAreEqual(TestFixedBitSetEqualsBackingCapacity.java:25)
...
:lucene:core:test (FAILURE): 1 test, 1 failure
...
1 test completed, 1 failed
...
BUILD FAILED in 1m 38s
# -Ptests.asserts=false: the same AssertionError; 10.5.2 (-ea and -da) and 9.12.3: the same
# with the fix: 1 test, 0 failures, both ways
# core-only probe, the same line on all 18 failing releases and pre-releases run (4.0.0-BETA .. 10.6.0) and main
length 64/64 cardinality 0/0 hashCode -1737092556/-1737092556 equals false/falseLongBitSet.equalsis documented as "returns true if both sets have the same bits set";FixedBitSet.equalssaid the same until a 2015 doc cleanup.- Both constructors allow a larger array;
hashCode,cardinality, iteration and bulk operations read only the words in use. - LUCENE-4693 (#5758) moved these classes to "the actual bound rather than the size of the array" but missed
equals.
The full argument300 words
- The documented contract.
LongBitSet.equals:/** returns true if both sets have the same bits set */(LongBitSet.java:427).FixedBitSet.equalscarried the same sentence (4.2.0FixedBitSet.java:410) until LUCENE-6427 (701b7ddf3a2, 2015) removed it in a "Deleted obsolete doc comments on @Override methods" cleanup of duplicated javadocs. - Oversized arrays are a documented input (
FixedBitSet.java:152-159,LongBitSet.java:85-92). hashCode,cardinality, iteration and the bulk operations use the logical bound. LUCENE-4693 (#5758, type:bug, 4.2.0,44f4f8b9492): with an oversizedlong[]"the FixedBitSet can return wrong results since we use words.length (bits.lenght) as the bounds ... We should use the actual bound rather than the size of the array" (as Simon Willnauer wrote there). That commit movedhashCode, iteration and the bulk operations to the logical word count;equalsstayed on the whole array. Its new test filled the unused words with random longs. A few methods do see the whole array:clear()clears it,ramBytesUsed()counts it,getBits()returns it,clone()copies it, andFixedBitSet.scanIsEmptycompares chunks that can reach pastnumWords(FixedBitSet.java:710-711). They report memory or representation, or read words the contract keeps clear, so with clear extra words none of them changes a result.- Tests.
TestFixedBitSet.makeFixedBitSet(:329-333) andTestLongBitSet.makeLongBitSet(:276-280) build oversized sets, but the equality tests (TestFixedBitSet.java:269-310) use exact-size sets only. - Precedent. The predecessor
OpenBitSet.equalshad the same javadoc and explicitly ignored trailing zero words (4.2.0OpenBitSet.java:869-893); LUCENE-2216 fixed, as a bug, "OpenBitSet.hashCode returned different hash codes for sets that only differed by trailing zeros" (CHANGES.txt:18372). - What it is not. Java's
equals/hashCodecontract is not broken: unequal objects may share a hash code. The defect is thatequalsdisagrees with the value semanticsLongBitSet.equalsdocuments, for inputs both constructors allow.
The devil's-advocate check156 words
The strongest case, from the cross-model reviewer (rated "moderate"): both classes are @lucene.internal, expose their array (getBits()), show their capacity in ramBytesUsed, clone and clear, and kept the whole-array equals through LUCENE-4693 and LUCENE-6427; FixedBitSet.equals has no javadoc today, and Object.equals does not itself demand value equality. So capacity could be part of the value.
Why it fails: @lucene.internal renders as "This API is for internal purposes only and might change in incompatible ways in the next release" (RenderJavadocPlugin.java:506), a compatibility notice, not a licence for wrong answers; upstream fixed this very defect family in these classes as bugs. LongBitSet.equals states value semantics in writing; FixedBitSet's identical sentence went in a doc cleanup, and neither issue thread discusses equals. hashCode was deliberately moved off the capacity (LUCENE-4693), which points the same way; the methods that do show the capacity report memory or representation, not which bits are set.
Recommended Compare only the first numWords words (validated)
Compare only the words in use, in both classes: return Arrays.equals(bits, 0, numWords, other.bits, 0, other.numWords);. Equal numBits means equal numWords; sets of the same capacity whose extra words are clear, as the constructors require, compare exactly as before. The repro passes, and Lucene's bit-set and automaton tests pass with assertions on and off.
All options and trade-offs2 options ยท 444 words
One fix was validated through gradle in a scratch worktree of 645d4fe14a20; a documentation-only alternative is listed but was not run.
FixedBitSet.java and LongBitSet.java, one line each:
--- a/lucene/core/src/java/org/apache/lucene/util/FixedBitSet.java
+++ b/lucene/core/src/java/org/apache/lucene/util/FixedBitSet.java
@@ -842,7 +842,7 @@
return false;
}
// Depends on the ghost bits being clear!
- return Arrays.equals(bits, other.bits);
+ return Arrays.equals(bits, 0, numWords, other.bits, 0, other.numWords);
}
--- a/lucene/core/src/java/org/apache/lucene/util/LongBitSet.java
+++ b/lucene/core/src/java/org/apache/lucene/util/LongBitSet.java
@@ -437,7 +437,7 @@
return false;
}
// Depends on the ghost bits being clear!
- return Arrays.equals(bits, other.bits);
+ return Arrays.equals(bits, 0, numWords, other.bits, 0, other.numWords);
}Trade-offs
numBitsis already checked equal, so both ranges have the same length; the fix uses the JDK's rangedArrays.equalsoverload and compares no more words than before (performance not measured).- It relies on clear ghost bits, which the constructors already require and
hashCodealready assumes; the "Depends on the ghost bits being clear!" comment stays true. The constructors check that only with assertions (assert verifyGhostBitsClear()), andgetBits()lets a caller break it later. - Sets of the same capacity whose extra words are clear compare exactly as before. Only sets whose extra words are not clear, a state the contract forbids, can compare differently: those words are now ignored. Like
hashCode, the fix still compares the unused high bits of the last word in use, which must be clear too.
What the checks validated
- Scratch worktree of
645d4fe14a20, gradle--offline, OpenJDK 25.0.3, seed DEADBEEF. Stock: the repro fails with assertions on and off. With the fix: the repro passes both ways;TestFixedBitSet,TestLongBitSet,TestSparseFixedBitSet,TestBitSetIteratorand the wholeorg.apache.lucene.util.automatontest package (home ofRunAutomaton, the only non-test caller found) pass (assertions on and off: 20 suites, 314 tests, 0 failures, 3 nightly/monster tests skipped); the:lucene:coreformat, ECJ and forbidden-apis checks pass. No stock run of the same 20 suites was recorded; the second adjudication ranTestFixedBitSet+TestLongBitSetstock and fixed. - Earlier runs: the shadowed fix makes the minimal test pass with
-eaand-da; the tie-break's LongBitSet test passes;TestFixedBitSet+TestLongBitSet44/44 with the fix over 2 seeds (tie-break) and 45 tests over 5 seeds (study); on 4.0.0 and 4.2.0 an equivalent fix over the words in use (written for those versions' fields) makes the history probe pass.
Change LongBitSet.equals' javadoc, and add one to FixedBitSet.equals, saying that sets over different-size arrays are never equal.
Trade-offs
Keeps today's behaviour, but leaves equals inconsistent with hashCode, cardinality, iteration and the bulk operations, and with the "may be larger" constructors. Not recommended; not run.
Lucene.NET's port has the same Arrays.Equals(bits, other.bits) line in FixedBitSet.Equals (by reading its source, not run).
Not a formal find. Error Prone's InconsistentHashCode check, which Lucene's build turns off as noisy, flagged FixedBitSet's equals/hashCode pair for a wrong reason; reading the flagged method found the real gap. An independent verifier reproduced it on the real jar.
The method in full105 words
A static-analysis pass over lucene-core (SpotBugs, Error Prone with its default checks plus 40 that Lucene turns off, CodeQL, PMD, Checker Framework) produced thousands of findings; 62 triage rows were read by hand. Error Prone's InconsistentHashCode reported "hashCode includes the fields [numWords], which equals does not". That reason is wrong: numWords follows from numBits, which equals does check. Reading the method showed the real problem, the whole-array Arrays.equals. The check is OFF in Lucene's build (ErrorPronePlugin.java:493, "noisy").
An independent verifier then wrote its own deterministic tests (4 fail, 3 controls pass) and validated the one-line fix against TestFixedBitSet and TestLongBitSet.
No report found (2026-11-01: GitHub issues and PRs incl. the JIRA archive, commits, mailing lists, JIRA, downstream trackers, the web; no open PR touches either equals). The closest is a 2020 Error Prone dump on lucene-solr#1816 that lists the same warning, without discussion.
Searches2 searches
Closest hits, none of them this bug:
- lucene-solr#1816 (LUCENE-9497, 2020): a pasted Error Prone dump includes
FixedBitSet.java:502: warning: [InconsistentHashCode] hashCode includes the fields [numWords], which equals does not, the same automated signal with the tool's own (wrong) reason; nobody replied about it. - #5758 (LUCENE-4693) and #7486 (LUCENE-6427): oversized arrays and ghost bits in these classes;
equalsis never discussed. #7469 (LUCENE-6409), where Luc Vanlerberghe wrote "Harmonize the use of numWords vs. bits.length vs. numBits": background only. - #3292 (LUCENE-2216): the mirror-image
OpenBitSet.hashCodebug, fixed in 2010. #6486 (LUCENE-5423): OpenBitSet, different cause. - #7802 (LUCENE-6744):
instanceofvsgetClassin theseequalsmethods. #7457 (LUCENE-6397): SparseFixedBitSet has noequals. - PR #15874 (2026-04-01): the last edit of both
equalsmethods (pattern variable); theArrays.equalsline was kept.
Not fixed upstream: main f815e679bf6c (2026-11-01), branch_11x, branch_10_6, tags 10.6.0 and 10.5.2, branch_9_12 and 9.12.3 all have the line.
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues, PRs and commits (apache/lucene incl. the migrated JIRA archive, apache/lucene-solr, Lucene.NET, Solr, Elasticsearch, OpenSearch, luceneutil, all of GitHub), dev@/java-user@/issues@/commits@ lists, JIRA, the web | 2026-11-01 | scripted searches, 51 queries (907 hits); 48 GraphQL and 17 REST searches (FixedBitSet, LongBitSet, equals, hashCode, Arrays.equals, numWords, bits.length, storedBits, ghost bits, oversized, capacity, InconsistentHashCode, ...); 32 items read in full; changed files of all 409 open PRs; git log -S/-L on both equals methods | no report; closest lucene-solr#1816 (tool warning, no discussion) |
| apache/lucene issues and PRs; the study's searches | 2026-10-20 to 2026-10-23 | GitHub text searches for FixedBitSet/LongBitSet equals, capacity, ghost bits | no report |
The equals line dates from FixedBitSet's creation (2011), when every array was exact. LUCENE-4069 (2012) added the oversized-array constructor, so 4.0.0 is the first affected release; all 123 release tags from 4.0.0-beta on carry the code, and every release run up to 10.6.0, and main, fails. LUCENE-4693 (2013) fixed the neighbouring methods but not equals.
Versions tested6 rows
Introduced by LUCENE-4069 ("Segment-level Bloom filters", Mark Harwood), committed 2012-08-02 as ad2cefee00a (trunk) and b889626bc4c (branch_4x): it added the public FixedBitSet(long[] storedBits, int numBits) constructor. The faulty line is older: return Arrays.equals(bits, other.bits); came with FixedBitSet itself (LUCENE-3280, a17df303e9d, 2011, 3.4.0). LongBitSet was created with the same equals (LUCENE-5440, b872f4b5c3c, 4.7.0). Missed fixes: LUCENE-4693 (#5758, 4.2.0) moved hashCode, iteration and the bulk operations to the logical word count; LUCENE-6427 (#7486, 5.2.0) documented the oversized input and edited both equals methods (a direct numBits compare, the "Depends on the ghost bits being clear!" comment, FixedBitSet's javadoc dropped) but kept the whole-array Arrays.equals. Neither issue thread mentions equals.
| Version or commit | Verdict | Result |
|---|---|---|
main 645d4fe14a20 | affected | minimal test fails through gradle (-ea, -da); probe fails |
| 9.12.3 to 10.6.0 | affected | minimal test and probe fail (9.12.3, 10.0.0, 10.5.2, 10.6.0 run) |
| 4.7.0 to 9.0.0 | LongBitSet too | probe fails (4.7.0, 4.10.4, 5.1.0, 5.2.0, 5.5.5, 6.6.6, 7.7.3, 8.11.4, 9.0.0 run); LongBitSet exists from 4.7.0 and fails the same way |
| 4.0.0 to 4.6.1 | FixedBitSet only | probe fails (4.0.0-BETA, 4.0.0, 4.1.0, 4.2.0, 4.6.1 run) |
| 3.4.0 to 3.6.2, 4.0.0-ALPHA | not affected | no FixedBitSet(long[], int) constructor, so no public way to build an oversized set (3.6.2 and 4.0.0-ALPHA run) |
| 4.0.0, 4.2.0, main + fix | not affected | a fix that compares only the words in use makes the probe (and on main the minimal test) pass; on 4.0.0 and 4.2.0 it is written for those versions' fields |
Reproduced by the study's independent verifier, two later reruns, a repro minimization, a re-verification on main and two releases, a cross-model refute review (OpenAI GPT-5.6, with execution), a tie-break and this doc's gradle runs. The reviewer agreed on every fact but judged it not real because the classes are internal API; the tie-break judged it real.
Checks and verdicts289 words
Every reproduction ran on stock builds; the fix checks ran with the patched FixedBitSet and LongBitSet only.
| Check | Verdict | What it found |
|---|---|---|
| Study verifier (static-analysis) | real, low | 7 tests on main 7cecf79523: 4 fail (FixedBitSet with bits, empty, HashSet.contains, LongBitSet), 3 controls pass; the fix passes TestFixedBitSet and TestLongBitSet over 5 seeds. |
| Re-runs on later main commits | reproduces | The study's tests on main b83101cdd2 and a9779ef3c193: 7 run, 4 fail. |
| Repro minimization (2026-10-29) | verified | The one-test repro fails -ea/-da; passes with the fix shadowed. |
| Re-verification on 645d4fe14a20 | real | Fails on main (-ea, -da), 10.5.2 (-ea, -da) and 9.12.3; passes with the fix; the study's 7 tests 4 fail on main. |
| Cross-model refute review (OpenAI GPT-5.6, with execution) | not real (internal API); facts agree | Own 70-bit test, both classes: fails -ea and -da; its control and fix runs were -ea only: control passes, fix passes; root cause and fix agreed. Judged it not real only because it treats @lucene.internal APIs as out of contract; rated the works-as-designed case moderate. |
| Second adjudication | real (high) | Re-ran the minimal test; own LongBitSet test (bit 3, long[1] vs long[2]): hashCode equal, equals false, passes with the fix; TestFixedBitSet + TestLongBitSet 44/44 stock and fixed, 2 seeds. |
| History follow-up | 4.0.0 first affected | Probe on 20 releases and pre-releases from 3.6.2 to 10.6.0, -ea and -da: 18 fail, 2 lack the constructor; the fix passes on 4.0.0 and 4.2.0. |
| This doc (2026-11-01) | reproduced, fix validated | Gradle in a scratch worktree: stock fails both ways; with the fix the repro, the bit-set and automaton tests and lint pass. |
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.
- PNG 412 KB
Finding record, Lucene and upstream sources, repro files and diagram notes553 words
- Registry title
- FixedBitSet and LongBitSet equals depend on the backing array capacity, not only on the bits
- Area
- util / bitsets
- Priority
- Low, rank 1215: FixedBitSet/LongBitSet equals depends on backing-array capacity, which only user code comparing sets built over oversized arrays can see, with no in-tree effect.
- Impact class
- minor
- Impact note
- Only callers that compare bit sets built with different backing capacities; no in-tree path is affected; equals gives false negatives only.
- Summary
- You compare two FixedBitSet (or LongBitSet) objects with the same length and bits that were built with different backing-array capacities. You expect equals to return true. Instead it returns false.
- Introduced
- LUCENE-4069 (b889626bc4c on branch_4x, ad2cefee00a on trunk), which added the oversized-array constructor FixedBitSet(long[], int); the whole-array equals line dates from LUCENE-3280 (a17df303e9d, 2011) [read] (2012-08-02)
- First affected version
- 4.0.0 [exec]
- Affected versions
- 4.0.0 to 10.6.0 and main (LongBitSet since 4.7.0) [exec: 18 releases and pre-releases from 4.0.0-BETA to 10.6.0, and main]
- Status
- documented (brief, checks and a per-finding doc exist)
- Verification
- Confirmed in the earlier formal verification study by independent AI checks and a rerun on Lucene main; also a cross-model review that disputed it (the study's other checks rated it real); not yet triaged by a person.
- Found by
- non-formal baseline: static analysis
- Upstream and prior reports
- none found (study searches 2026-10-22; searched again 2026-11-01)
- Lucene commit tested
- main
645d4fe14a20(2026-11-01): the minimal test fails through gradle in a scratch worktree with assertions on and off; with the one-line fix in both classes it passes both ways, andTestFixedBitSet,TestLongBitSet,TestSparseFixedBitSet,TestBitSetIteratorand the wholeorg.apache.lucene.util.automatontest package (home ofRunAutomaton, the only non-test caller found) pass (assertions on and off: 20 suites, 314 tests, 0 failures, 3 nightly/monster tests skipped); the:lucene:coreformat, ECJ and forbidden-apis checks pass. Standalone, the minimal test fails on 10.5.2 (-ea,-da) and 9.12.3; the version-history core-only probe fails on all 18 releases and pre-releases run from 4.0.0-BETA to 10.6.0 (a sweep of all 129 release tags finds the same code on every tag from 4.0.0-beta on) and passes on 4.0.0 and 4.2.0 with an equivalent fix over the words in use.
- The finding in 30 seconds
a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/util/FixedBitSet.java:42, 81, 146-150, 152-170, 183-193, 826-830, 833-846, 848-859a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/util/LongBitSet.java:34, 55, 85-103, 420-424, 427-441, 443-454
- Who is affected
a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/util/automaton/RunAutomaton.java:69, 200; util/bkd/DocIdsWriter.java:276-284a Lucene main checkout (645d4fe14a20) lucene/core/src/test/org/apache/lucene/util/TestFixedBitSet.java:274-284, TestLongBitSet.java:201-211; lucene/core/src/test/org/apache/lucene/search/TestScorerPerf.java:35, 131, TestBooleanQuery.java:424-426, 461; lucene/test-framework/src/java/org/apache/lucene/tests/search/FixedBitSetCollector.java:34, 65
- Why it looks unintended
a Lucene main checkout (645d4fe14a20) LongBitSet.java:85-92, 427; FixedBitSet.java:152-159, 172-175, 200-203, 205-208, 702-714, 826-830; LongBitSet.java:128-131, 420-424, 456-459; lucene/core/src/test/org/apache/lucene/util/TestFixedBitSet.java:269-310, 329-333; TestLongBitSet.java:276-280; lucene/CHANGES.txt:18372a Lucene main checkout (645d4fe14a20) build-tools/build-infra/src/main/java/org/apache/lucene/gradle/plugins/java/RenderJavadocPlugin.java:506
- How it was found
a Lucene main checkout (645d4fe14a20) build-tools/build-infra/src/main/java/org/apache/lucene/gradle/plugins/java/ErrorPronePlugin.java:493
- Diagrams
a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/util/FixedBitSet.java:152-170, 833-846, 848-859
- TestFixedBitSetEqualsBackingCapacity.java (one test class):
repro/LFV-025/src/org/apache/lucene/util/TestFixedBitSetEqualsBackingCapacity.java(30 lines)
Made in parallel with this doc from the code at 645d4fe14a20 and the executed runs.
assets/LFV-025/bug.png(412 KB)
![Diagram of the bug: new FixedBitSet(64) is backed by one 64-bit word, new FixedBitSet(new long[2], 64) by two words, the second all zero. Both have length 64, cardinality 0 and hashCode 0x98761234; hashCode reads only the numWords = 1 word in use. equals checks numBits (64 = 64) and then calls Arrays.equals on the whole arrays, which compares their lengths, 1 and 2, and returns false. With the fix, equals compares only the first numWords words and returns true.](../assets/LFV-025/bug.png)