copyBytes keeps the number of bytes still to copy in an int. From 2^31 bytes on, that int is negative (or a smaller, wrong number), so the copy stops at once and the method returns having copied nothing, or only part.
ByteBuffersDataOutputis Lucene's in-heap output: it appends bytes to a list ofByteBufferblocks and can hold more than 2 GiB (size()is along).ByteBuffersDirectorykeeps each file in one of them.DataOutput.copyBytes(input, numBytes)readsnumBytesbytes from an input and writes them to the output;numBytesis along.Directory.copyFromuses it to copy a whole file.- A Java
intholds at most 2,147,483,647 (2^31-1). Casting a largerlongtointkeeps only its low 32 bits: 2^31 becomes -2,147,483,648, and 2^32+5 becomes 5.
ByteBuffersDirectory arrives with length 0: the long length becomes a negative int, so the copy loop never starts. With the count kept in a long, every byte is copied. Select the still to open it at full size. Generated with AI, checked against the code.- You copy 2^31 = 2,147,483,648 bytes into an in-heap
ByteBuffersDataOutput:out.copyBytes(in, 1L << 31).Directory.copyFrommakes the same call when aByteBuffersDirectoryreceives a file of 2 GiB or more, for example inIndexWriter.addIndexes(Directory...). - You expect
out.size()to be 2,147,483,648:copyBytespromises to "Copy numBytes bytes from input to ourself", and Lucene 9.2.0 does exactly that. - But the method starts with
int length = (int) numBytes;, which is -2,147,483,648. The copy loop and its fallback run only whilelength > 0, so it returns at once: no byte read, no exception, with assertions on or off. - You get
out.size()0. Every length from 2^31 to 2^32-1 copies nothing; 2^32+5 copies 5 bytes. - Through
addIndexes, a 2,169,703,755-byte_0.fdtarrives as an empty file andcommit()succeeds; the nextDirectoryReader.openthrowsCorruptIndexException.
Code that copies one file or stream of 2 GiB or more into a ByteBuffersDataOutput, ByteBuffersIndexOutput or ByteBuffersDirectory, for example to load an on-disk index into heap with copyFrom or addIndexes. Opt-in (the default FSDirectory is not affected) and extreme scale for a directory documented for small indexes, but silent at copy time.
Mechanism, terms and numbers570 words
ByteBuffersDataOutput.copyBytes(DataInput input, long numBytes) begins with int length = (int) numBytes; (ByteBuffersDataOutput.java:314 on main 645d4fe14a20). Both the loop that reads straight into the block arrays and the fallback to the long-safe base method run only while that int is positive, and the only check, assert numBytes >= 0, tests the long. So the method copies max(0, (int) numBytes) bytes and returns normally: every length up to Integer.MAX_VALUE is correct, every length from 2^31 to 2^32-1 copies nothing, and 2^32+5 copies 5 bytes. The rest of the input is left unread.
ByteBuffersIndexOutput.copyBytes delegates to it, and ByteBuffersDirectory.copyFrom (from BaseDirectory) copies a file with os.copyBytes(is, is.length()). A file of 2 GiB or more copied into a ByteBuffersDirectory therefore comes out empty or short, with no error. Through IndexWriter.addIndexes(Directory...) into a ByteBuffersDirectory, the copy and commit() succeed and the next DirectoryReader.open throws CorruptIndexException. The override came with LUCENE-10657 (#1034) in 9.3.0 as an optimisation; 9.2.0 copied correctly. Every release from 9.3.0 to 10.6.0 and main is affected; the default FSDirectory outputs are not. The fix is two lines.
- The narrowing.
store/ByteBuffersDataOutput.java:311-332:int length = (int) numBytes;(:314), thenwhile (length > 0) { ... }(:315) copies chunk by chunk straight into the current block's array, andif (length > 0) super.copyBytes(input, length);(:329-331) handles blocks without an array. When(int) numBytes <= 0both are skipped. The only check,assert numBytes >= 0(:313), tests thelong. - What it overrides.
DataOutput.copyBytescounts down along leftin 16 KiB chunks and is correct for any length (store/DataOutput.java:280-293).ByteBuffersDataOutputinherited it until 9.3.0. - How users reach it.
ByteBuffersIndexOutput.copyBytesdelegates (store/ByteBuffersIndexOutput.java:139-143).ByteBuffersDirectoryextendsBaseDirectory(store/ByteBuffersDirectory.java:50), whosecopyFromcallscopyThroughCreateOutput(store/BaseDirectory.java:54-58), which doesos.copyBytes(is, is.length())(store/Directory.java:189-197, line 193).IndexWriter.addIndexes(Directory...)(index/IndexWriter.java:3103) copies each segment file withdirectory.copyFromincopySegmentAsIs(:3648,:3692); thatdirectoryis the writer'sLockValidatingDirectoryWrapper(:958), whosecopyFromchecks the lock and delegates (store/LockValidatingDirectoryWrapper.java:47-51). Wrapped destinations reach the same method too (code reading, not run):FilterDirectory.copyFromgoes through its owncreateOutput(store/FilterDirectory.java:126-128),FileSwitchDirectory.copyFromdelegates to the directory chosen for the file (store/FileSwitchDirectory.java:241-244), andNRTCachingDirectory, aFilterDirectory, writes into an internalByteBuffersDirectorywhen theIOContextsize estimate fits bothmaxMergeSizeMBandmaxCachedMB(store/NRTCachingDirectory.java:72-84,:155-165,:231-245; foraddIndexesthe estimate is the whole segment,index/IndexWriter.java:3174-3175), so it needs both limits set above the segment's size. - Not affected. Outside test code, the only
copyBytes(DataInput, long)overrides in Lucene are this one, the delegatingByteBuffersIndexOutputandEndiannessReverserDataOutput(backward-codecs), and the base method; theFSDirectoryfamily writes through outputs that inherit the long-safe base method. Copying from aByteBuffersDirectoryto disk is fine.
requested numBytes | (int) numBytes | bytes copied |
|---|---|---|
| up to 2,147,483,647 | same | all (correct) |
| 2^31 = 2,147,483,648, and 2^31+7 | negative | 0 |
| 2^32+5 = 4,294,967,301 | 5 | 5 |
| 2^32+2^31+1 | negative | 0 |
Through addIndexes (executed on main ea57360912ed, default settings): a source index whose _0.fdt is 2,169,703,755 bytes; addIndexes and commit() return normally; the copied _0.fdt has length 0; DirectoryReader.open throws CorruptIndexException (cause EOFException); CheckIndex reports open reader.........FAILED. The source index is not modified.
Exposure and likelihood307 words
- Who is hit. A direct
copyBytescall of 2^31 bytes or more on aByteBuffersDataOutputorByteBuffersIndexOutput;Directory.copyFromof a file of 2 GiB or more into aByteBuffersDirectory;IndexWriter.addIndexes(Directory...)with aByteBuffersDirectoryas the writer's directory and a source file of 2 GiB or more. That is the "load an on-disk index into RAM" pattern; LUCENE-8624 (#9670) was reported by a user indexing large data sets withByteBuffersDirectory. - Default vs opt-in. Opt-in: the default directory (
FSDirectory.open) and copies from aByteBuffersDirectoryto disk are not affected. Nothing else needs to be configured once the destination is heap-based. - Scale. One copy of 2^31 bytes or more; every smaller copy is correct.
- What goes wrong. Deterministic and silent at copy time, with assertions on and off. A directly copied stream or a copied non-index file is empty or truncated with no error. A copied index commits normally and fails only at the next
DirectoryReader.open, away from the copy that caused it. The source is never modified, and on this path search results are never silently wrong. - Likelihood: contrived.
ByteBuffersDirectoryis@lucene.experimental; its javadoc saysMMapDirectory"is nearly always a better choice" and a heap-based directory can have the advantage for "ephemeral, small, short-lived indexes" (store/ByteBuffersDirectory.java:43-48);lucene/MIGRATE.mdsays it is "discouraged in favor of the defaultMMapDirectory". No public report was found in about four years of affected releases. - Not claimed: a second caller. The default terms-index builder
TrieBuilder.append(10.5.0 and later) bulk-copies a sub-trie buffer with this method (codecs/lucene103/blocktree/TrieBuilder.java:153-156). At unit level (package-private API), appending a 2,176,663,776-byte sub-trie kept 3 of 68,003 entries, a 32 MB control kept all, and the fix kept all 68,003. It was not reproduced throughIndexWriter, so it is not part of this report; the same two-line fix covers it.
One JUnit test, one method: copy 2^31 zero bytes from an inline DataInput into a ByteBuffersDataOutput and check size(). Public API, no index, deterministic. On stock Lucene it allocates nothing and fails in about 2 seconds, with assertions on and off.
package org.apache.lucene.store;
import java.io.IOException;
import org.apache.lucene.tests.util.LuceneTestCase;
public class TestByteBuffersDataOutputCopyBytes2GiB extends LuceneTestCase {
public void testCopyBytesCopiesAllBytes() throws IOException {
DataInput zeros =
new DataInput() {
@Override
public byte readByte() {
return 0;
}
@Override
public void readBytes(byte[] b, int offset, int len) {}
@Override
public void skipBytes(long numBytes) {}
};
long numBytes = 1L << 31;
ByteBuffersDataOutput out = new ByteBuffersDataOutput();
out.copyBytes(zeros, numBytes);
// expected: size() == 2^31 (every byte copied); actual: 0, since (int) numBytes is negative
assertEquals(numBytes, out.size());
}
}
cd $LUCENE # an apache/lucene checkout at 645d4fe14a20 (or 10.6.0, branch_10_6, upstream main)
cp $REPRO/TestByteBuffersDataOutputCopyBytes2GiB.java lucene/core/src/test/org/apache/lucene/store/
./gradlew :lucene:core:test --tests org.apache.lucene.store.TestByteBuffersDataOutputCopyBytes2GiB -Ptests.seed=DEADBEEF
# assertions off fails the same way: add -Ptests.asserts=false
# after a fix the copy really holds 2 GiB: add -Ptests.heapsize=4g
- Expected
out.size()is 2147483648 aftercopyBytes(zeros, 1L << 31), as on Lucene 9.2.0.- Observed
expected:<2147483648> but was:<0>: nothing copied and no exception, on main and 10.6.0, assertions on and off.
More files, environment, full output140 words
The length is the smallest failing one, 2^31.
As a regression test it has one cost: once fixed, the copy really holds 2 GiB, so the test runs out of memory with Lucene's default 512 MB test heap (executed) and needs a larger heap (-Ptests.heapsize=4g) or the @Monster annotation that Lucene's 2B tests use.
| lucene | main 645d4fe14a20 (11.0.0-SNAPSHOT); also the 10.6.0 and 9.3.0 release jars with this test (9.2.0 passes) |
|---|---|
| jdk | OpenJDK 25.0.3 |
| os | Linux, aarch64 |
TestByteBuffersDataOutputCopyBytes2GiB > testCopyBytesCopiesAllBytes FAILED
java.lang.AssertionError: expected:<2147483648> but was:<0>
at __randomizedtesting.SeedInfo.seed([DEADBEEF:F37EE34A02796FF1]: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.store.TestByteBuffersDataOutputCopyBytes2GiB.testCopyBytesCopiesAllBytes(TestByteBuffersDataOutputCopyBytes2GiB.java:26)
:lucene:core:test (FAILURE): 1 test, 1 failure
# the same failure with -Ptests.asserts=false, and with a standalone JUnit runner on main and 10.6.0 (assertions on and off) and 9.3.0
# 9.2.0: 1 test, 0 failures (a real 2 GiB copy)DataOutput.copyBytestakes alongand promises to "Copy numBytes bytes"; the base method it overrides works for any length.- The class can hold more than 2 GiB:
size()is along, andtoArrayCopy()throws a clear error rather than drop data. - 9.2.0 copies 2^31 bytes correctly, and the same cast was fixed in the sibling
BytesStore.copyBytesin review (#1047).
The full argument307 words
- The contract.
DataOutput.copyBytes: "Copy numBytes bytes from input to ourself." (DataOutput.java:280), with alongparameter and a base loop on along(:281-293).Directory.copyFrom: "Copies an existing {@code src} file from directory {@code from} to a non-existent file {@code dest} in this directory." (Directory.java:174-175). Neither states a size limit. - The class handles more than 2 GiB.
ByteBuffersDataOutput.size()returns along(:349);toArrayCopy()documents and throws aRuntimeExceptionwhen "the {@link #size()} of the underlying buffers exceeds maximum size of Java array" (:285-298). - A regression of an optimisation. LUCENE-10657 (#1034, CHANGES 9.3.0 "Optimizations": "CopyBytes now saves one memory copy on ByteBuffersDataOutput") added the override; its issue names
IndexWriter#copySegmentAsIs -> copyFrom -> copyBytes, a whole-file copy, as a target. The review covered the fallback for non-heap buffers andarrayOffset, not the length type; its two tests copy about 8 KiB. 9.2.0, which inherits the base method, copies 2^31 bytes correctly (executed). - The sibling fix. In #1047 (LUCENE-10661, the same author's
BytesStore.copyBytesoverride, a week later), reviewer jpountz wrote "We could makelena long and avoid the unchecked cast, couldn't we?", andBytesStoregot along lenwith a per-chunk(int) Math.min(...), the fix proposed here.ByteBuffersDataOutputwas not revisited. - Precedent. LUCENE-8624 (#9670, an
intoverflow inByteBuffersDataOutput.size()) and LUCENE-8625 (#9671,ByteBuffersDataInput.sliceBufferList) were fixed as bugs in 7.7.0 (CHANGES 7.7.0 "Bug fixes"); LUCENE-8628 (#9674) was closed as a duplicate report of the same overflow. - No test tolerates it. We found no upstream test that copies 2^31 bytes or more. The two tests added with the override copy about 8 KiB (
TestByteBuffersDataOutput.java:201-233).BaseDirectoryTestCase, which also runs forByteBuffersDirectory(TestByteBuffersDirectory.java:34), copies files of at most 1,777,777 bytes, in chunks of up to 77,777 bytes (testCopyBytes,BaseDirectoryTestCase.java:799-837).
The devil's-advocate check127 words
The strongest case: ByteBuffersDirectory is experimental and its javadoc points to "ephemeral, small, short-lived indexes", MIGRATE.md discourages it in favour of MMapDirectory, nobody should hold 2 GiB files in heap buffers, and an index copied this way does fail loudly when it is opened.
Why it fails: that is usage advice, not a documented size precondition, and it says nothing about ByteBuffersDataOutput, a public class that is not marked experimental. The method keeps a long signature and returns normally after copying nothing, where 9.2.0 did the copy. "Fails at open" covers only index files, and only later; direct callers and other files get no error at all. The cross-model reviews rated the "intended" case weak, and the devil's advocate verifier and the adjudication rejected it.
Recommended Keep the remaining count in a long (2 lines)
Keep the remaining count in a long and cast only each chunk: long length = numBytes; and int chunk = (int) Math.min(currentBlock.remaining(), length);. With these two lines the test passes (assertions on and off, 4 GB test heap), and the store tests, TestTrie and :lucene:core:check -x test pass.
@@ -314 +314 @@ public void copyBytes(DataInput input, long numBytes)
- int length = (int) numBytes;
+ long length = numBytes;
@@ -322 +322 @@
- int chunk = Math.min(currentBlock.remaining(), length);
+ int chunk = (int) Math.min(currentBlock.remaining(), length);All options and trade-offs3 options ยท 496 words
The recommended fix was run in a scratch worktree of 645d4fe14a20 (OpenJDK 25.0.3, seed DEADBEEF), removed afterwards. Options B and C were not run.
Each chunk is at most currentBlock.remaining(), an int, so the per-chunk cast is safe, and the fallback super.copyBytes(input, length) already takes a long. This is the pattern BytesStore got in #1047, and the same two lines as the finder's and the independent reproducer's fixes. The study's Z3 model of the copy count does not encode the loop, so it adds nothing here beyond "a long count does not narrow"; the evidence for the fix is the code and the executed tests.
@@ -314 +314 @@ public void copyBytes(DataInput input, long numBytes)
- int length = (int) numBytes;
+ long length = numBytes;
@@ -322 +322 @@
- int chunk = Math.min(currentBlock.remaining(), length);
+ int chunk = (int) Math.min(currentBlock.remaining(), length);Trade-offs
No change for copies below 2 GiB (same code path); copies of 2 GiB or more now really copy and need the heap for it. The one-copy optimisation is kept. Not fixed by it (code reading, not run): a ByteBuffersDirectory built with the non-default OUTPUT_AS_ONE_BUFFER (also OUTPUT_AS_BYTE_ARRAY) factory still cannot keep a file of 2 GiB or more, because closing the output calls toArrayCopy(), which throws (ByteBuffersDirectory.java:64-81, :264-266; ByteBuffersDataOutput.java:285-298). That failure is loud; the default OUTPUT_AS_MANY_BUFFERS (ByteBuffersDirectory.java:111) has no such limit.
What the checks validated
Executed in a scratch worktree of 645d4fe14a20 with gradle:
- the minimal test: stock 1 of 1 fails with assertions on and off; with the fix it passes with assertions on and off (
-Ptests.heapsize=4g, 1.8 s and 1.7 s); with the default 512 MB test heap the fixed copy runs out of memory, because it now really holds 2 GiB; org.apache.lucene.store.*andTestTrie: 780 tests, 18 skipped, 0 failures;:lucene:core:check -x test: passes (google-java-format, ecjLint, forbiddenApis).
Earlier runs of the same two lines: the independent reproducer's 9 tests pass, including a real 2^31+7-byte copyFrom into a ByteBuffersDirectory with content checks (re-run by the adjudication); the 16 existing TestByteBuffersDataOutput tests pass; the unit-level trie probe keeps all 68,003 entries. These are targeted runs, not the full Lucene test suite.
if (numBytes > Integer.MAX_VALUE) { super.copyBytes(input, numBytes); return; } before the loop.
Trade-offs
Correct by construction (the base method is long-safe) and small, but copies of 2 GiB or more lose the one-copy optimisation, and the int arithmetic stays for the next reader to wonder about.
int length = Math.toIntExact(numBytes); throws ArithmeticException for 2^31 or more.
Trade-offs
Removes the silent part (the adjudication listed it as acceptable), but rejects copies that 9.2.0 performed and that the class otherwise supports, so copyFrom of a large file into a ByteBuffersDirectory would still not work.
Products that load an on-disk index with a file of 2 GiB or more into heap through ByteBuffersDirectory see that file copied empty, or a CorruptIndexException at the next open, far from the copy that caused it.
Code reading while reviewing Lucene's DataInput and DataOutput implementations spotted the (int) numBytes cast. A small Z3 bit-vector model of the copy count then gave the affected lengths (smallest 2^31); a public-API JUnit test confirmed it on the stock jar.
The method in full199 words
Method: non-formal (code reading), with an SMT model used to characterise the inputs, not to find the bug.
- The finder reviewed
ByteBuffersDataOutputas part of a hunt unit on DataInput/DataOutput implementations and strings, and noticed that thecopyBytesoverride narrows itslonglength. - A Z3 bit-vector model of the copy count (5 queries) encodes the override as copying
max(0, (int) numBytes)bytes. In that model it found the minimal failing length 2^31, showed every length up toInteger.MAX_VALUEcorrect, found lengths in [2^31, 2^32) that copy nothing (every such length casts to a negativeint) and larger lengths that copy part of the request. Its fix query only confirms that alongcount has no narrowing: the model does not encode the copy loop, so the fix rests on the code (each chunk is at mostremaining()) and the executed tests. - A public-API JUnit on the stock jar (a direct copy, and
ByteBuffersDirectory.copyFromof a sparse 2^31+7-byte file) failed on main with assertions on and off. A cross-model reviewer, an independent reproducer, a second cross-model reviewer, a devil's advocate verifier and an adjudication then confirmed it, and the repro was later cut to the one test above.
No prior report found: three search-tool runs, 36 GitHub queries that read every result, the changed files of all 412 open PRs, the mailing lists, the JIRA archive and Stack Overflow (2026-10-26), refreshed on GitHub on 2026-10-31. Not fixed upstream.
Searches3 searches
Verdict new. Related, not reports of this bug: #11693 / #1034 (LUCENE-10657, introduced it); #1047 / #11697 (LUCENE-10661, where the same kind of cast was fixed in BytesStore); #987 (LUCENE-10627, where the override was first suggested); #9670, #9671 (LUCENE-8624/8625, 2018 overflows in other methods, fixed in 7.7.0) and #9674 (LUCENE-8628, a duplicate report of the same overflow); #16530, #16539, #16173, #14985 (copyFrom routing, not lengths); open #16245, #11992, #15186 touch the files but not copyBytes. Limits: GitHub issue search does not index code or diffs (covered in part by the open-PR file scan); list and JIRA search are word-based; private reports are not visible.
| Where | When | How | Found |
|---|---|---|---|
| Search tool: apache/lucene issues, PRs and commits, Elasticsearch, OpenSearch, Solr, dev@, java-user@, issues@, commits@, JIRA archive, web | 2026-10-26 | three runs (the finder's, the devil's advocate verifier's and the follow-up's); the follow-up's run: 14 GitHub, 3 commit, 2 downstream, 24 list, 4 JIRA and 6 web queries, 217 distinct hits | no report; 0 high-overlap hits, all 11 medium hits read |
| GitHub GraphQL search (apache/lucene, the three downstream repositories, all of GitHub); open PRs; Stack Overflow | 2026-10-26 | 36 queries that read every result, including the bare searches copyBytes (47) and copyFrom (32); the changed files of all 412 open PRs; the #11693 and #1034 threads in full | no report; #1047 is the closest related discussion |
| GitHub issues and PRs (apache/lucene), refresh | 2026-10-31 | "copyBytes", "ByteBuffersDataOutput", "ByteBuffersDirectory copyFrom", "copyBytes 2GB", "copyBytes int overflow", "ByteBuffersIndexOutput", "ByteBuffersDirectory addIndexes"; upstream commits on the file since 2026-10-20; the line on upstream main and branch_10_6 | nothing new; no commit on the file; the line is still on main and branch_10_6 |
Introduced in 9.3.0 by LUCENE-10657 (e5bf76b84304, #1034, merged 2022-07-19), an optimisation that overrode the long-safe copyBytes. 7.5.0 (where the class was added) to 9.2.0 use the long-safe base method (8.0.0, 8.11.4 and 9.2.0 executed); every release from 9.3.0 to 10.6.0 and main is affected; not fixed upstream.
Versions tested9 rows
Introduced by e5bf76b84304 on main and the branch_9x backport 7328ad2dafc2, "LUCENE-10657: CopyBytes now saves one memory copy on ByteBuffersDataOutput (#1034)" (author luyuncheng, 2022-07-19; issue #11693). git log -S 'int length = (int) numBytes' on main's history returns only this commit (the backport carries the same line), and the method body has not changed since.
Before 9.3.0 the class (added in 7.5.0 by LUCENE-8468, CHANGES 7.5.0) inherited the long-safe base copyBytes: no release from 7.5.0 to 9.2.0 has an override (read: in the file's history up to 9.3.0 only the backport 7328ad2dafc2 adds one; checked at 7.5.0, 7.7.3, 8.0.0 and 9.2.0), and 8.0.0, 8.11.4 and 9.2.0 copy 2^31+7 bytes correctly (executed). The method body is identical at every release tag from 9.3.0 to 10.6.0 and on main. branch_10x was decommissioned upstream on 2026-10-29 (#16911).
| Version or commit | Verdict | Result |
|---|---|---|
main 645d4fe14a20 | affected | the minimal test fails (gradle and a standalone runner, assertions on and off) |
| 10.6.0 | affected | the minimal test fails (assertions on and off); method body identical to main |
| 10.0.0, 10.5.2 | affected | the study's 2-test repro fails 2 of 2 |
| 9.3.0, 9.12.3 | 9.3.0: first affected | the minimal test (9.3.0) and the study's repro fail; the other releases 9.3.0 to 10.5.2 have the identical method body |
| 9.2.0 | last good | the minimal test passes (a real 2 GiB copy) |
| 8.0.0, 8.11.4 | not affected | a core-only probe copies 2^31+7 bytes correctly |
| 7.5.0 to 7.7.3 | read | the class exists (added in 7.5.0 by LUCENE-8468) with no copyBytes override, so it inherits the long-safe base method |
upstream main 2f424fd428d, branch_10_6 | read | the narrowing line is still there (2026-10-31) |
fix on 645d4fe14a20 | not affected | the minimal test passes (assertions on and off, 4 GB test heap); store tests and TestTrie: 780 tests, 0 failures |
Cross-model reviews (OpenAI GPT-5.6, both ran code), an independent reproducer, a devil's advocate and an adjudication all rated it real, with no dissent; the devil's advocate also executed the addIndexes reach. It still reproduces on main and 10.6.0.
Checks and verdicts264 words
Neither cross-model reviewer could run a fixed 2 GiB copy, so they used allocation-free probes; the independent reproducer and the adjudication ran the real copy.
| Check | Verdict | What it found |
|---|---|---|
| Finder (code reading, Z3 length model, public-API JUnit) | candidate | 2-test JUnit fails on main (assertions on and off), 9.3.0, 9.12.3, 10.0.0 and 10.5.2; passes on 9.2.0 and with the fix. |
| Cross-model review, refute lens (OpenAI GPT-5.6) | real (high) | Own test: a 2^31+1 request consumes 0 bytes; a 2^31 request against an input that throws on its first read returns normally; the fix passes the 16 existing TestByteBuffersDataOutput tests. |
| Independent reproducer | real | Own 9-test JUnit: 7 fail on main, 10.5.2, 9.12.3 and 9.3.0, 2 controls pass; 9 of 9 pass on 9.2.0 and with its fix. |
| Cross-model adversary, intended lens (OpenAI GPT-5.6) | real (high) | "Small indexes" is usage advice, not a size limit; own tests fail on main and 9.3.0, pass on 9.2.0 and with the fix. |
| Devil's advocate | real | No intended argument survives; addIndexes into a ByteBuffersDirectory commits a copy with an empty _0.fdt that fails to open. |
| Adjudication | confirmed, high | Re-ran the reproducer's test (7 of 9 fail, assertions on and off), its fix (9 of 9 pass) and the finder's repro (2 of 2 fail). |
| Current-main check and minimal repro | still reproduces | Main a9779ef3c193 and 645d4fe14a20; the repro cut to one test at the smallest failing length. |
| This doc | still reproduces; fix validated | Gradle on a scratch worktree of 645d4fe14a20; the 10.6.0, 9.3.0 and 9.2.0 jars. |
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 443 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,273 words
- Registry title
- ByteBuffersDataOutput.copyBytes narrows its long length to a signed int, so a copy of 2 GiB or more (for example Directory.copyFrom into a ByteBuffersDirectory) silently copies nothing or only (int) numBytes bytes
- Area
- store / ByteBuffersDataOutput
- Priority
- Low, rank 902: ByteBuffersDataOutput.copyBytes narrows a 2 GiB or larger copy to int, leaving in-heap copies empty or truncated (index copies fail at the next open), extreme scale for a directory meant for small indexes.
- Impact class
- data corruption
- Impact note
- Applications that copy a single file or stream of 2 GiB or more into an in-heap ByteBuffersDataOutput, ByteBuffersIndexOutput or ByteBuffersDirectory (a direct copyBytes call, Directory.copyFrom, or IndexWriter.addIndexes(Directory...) into a ByteBuffersDirectory-backed writer); non-default directory (the default FSDirectory outputs use the long-safe base copyBytes), extreme scale (one copy of 2^31 bytes or more), deterministic and silent with -ea and -da: direct callers and copied non-codec files end up empty or truncated, and a copied index commits normally but fails at the next DirectoryReader.open with CorruptIndexException (the source is untouched); released 9.3.0-10.6.0 (10.6.0 executed); a second, unverified path through the default terms-index builder (TrieBuilder.append, 10.5.0 and later) was not reproduced through IndexWriter and is not part of this finding.
- Summary
- You copy 2 GiB or more into an in-heap ByteBuffersDataOutput, for example with Directory.copyFrom or IndexWriter.addIndexes(Directory) into a ByteBuffersDirectory. You expect every byte to be copied, as DataOutput.copyBytes(DataInput, long numBytes) promises ('Copy numBytes bytes from input to ourself') and as Lucene 9.2.0 does. Instead the override stores the length in an int, copies max(0, (int) numBytes) bytes (nothing at all for any length from 2^31 to 2^32-1) and returns normally, so the destination file is empty or truncated with no exception; a copied index then fails to open with CorruptIndexException.
- Introduced
- e5bf76b84304 (LUCENE-10657, #1034 'CopyBytes now saves one memory copy on ByteBuffersDataOutput', issue #11693, main), released through its branch_9x backport 7328ad2dafc2 [read: git log -S on the narrowing line and on the override signature each return only e5bf76b84304, git blame of ByteBuffersDataOutput.java:311-332, git tag --contains, git merge-base --is-ancestor; exec: 9.2.0 passes and 9.3.0 fails the same test]; a regression of an optimisation: the overridden base DataOutput.copyBytes counts down a long (2022-07-19 (#1034 merged into main; branch_9x backport 7328ad2dafc2 the same day; 9.3.0 release tag dated 2022-07-28))
- First affected version
- 9.3.0 [exec] (first 10.x: 10.0.0 [exec])
- Affected versions
- 9.3.0 through 10.6.0 (31 releases) and main ea57360912ed: 9.3.0, 9.12.3, 10.0.0, 10.5.2 and main executed with the finder repro (main also with -da), the independent reproduction's repro fails on main, 10.5.2 and 9.12.3 with -ea and -da and on 9.3.0 with -ea [exec]; the other releases by source identity of the method body at every tag 9.3.0..10.5.2 [read]; unaffected: 8.0.0 through 9.2.0 (8.0.0, 8.11.4 and 9.2.0 executed, pass, with a real 2^31+7-byte copy) [exec]; branch_10x, branch_10_6 and branch_9_12 still carry the narrowing line [read, upstream contents API, 2026-10-26], and 10.6.0 (released 2026-10-29) is affected [exec 2026-10-31: the minimal test fails on the 10.6.0 release jars (SHA-1 checked against Maven Central) with -ea and -da]; not fixed upstream as of main ea57360912ed [read]
- 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
- non-formal: code reading; also reached by Z3
- Upstream and prior reports
- none found (2026-10-26: scripted prior-report searches by the finder (16 GitHub issue/PR, 3 commit, 2 downstream, 20 mailing-list and 4 JIRA queries), by the intended-behaviour check (16 GitHub, 20 list) and in a later search (14 GitHub issue/PR, 3 commit, 2 downstream, 24 mailing-list over dev@, java-user@, issues@ and commits@, 4 JIRA-archive and 6 web queries; 217 distinct hits, 0 high, 11 medium, all read); 36 GitHub GraphQL queries that read every result over apache/lucene, Elasticsearch, OpenSearch, Solr and all of GitHub, including the bare searches copyBytes (47) and copyFrom (32); the changed-file lists of all 412 open PRs; the mailing-list archive with no date cut-off; Stack Overflow; the #11693 and #1034 threads read in full; upstream main, branch_10x, branch_10_6 and branch_9_12 still have the line); related prior items, none of them this bug: #11693 / #1034 (LUCENE-10657, introduced it; the review covered direct buffers and arrayOffset, not the length type), #1047 / #11697 (LUCENE-10661, the same author's sibling BytesStore.copyBytes override eight days later: reviewer jpountz asked to 'make len a long and avoid the unchecked cast' and BytesStore got that fix, ByteBuffersDataOutput was not revisited), #987 (LUCENE-10627, where the override was first suggested), #9670 (LUCENE-8624, int overflow in ByteBuffersDataOutput.size()), #9671 (LUCENE-8625) and #9674 (LUCENE-8628), the 2018 overflow family in other methods, fixed in 7.7.0 before the override existed; #16530 and related copyFrom routing changes (#16539, #16173, #14985: bookkeeping and failure cleanup, not lengths); open PRs touching the files (#16245, #11992, #15186) do not change copyBytes
- Lucene commit tested
- main
645d4fe14a20(gradle in a scratch worktree and the standalone runner, assertions on and off); the 10.6.0 release jars (assertions on and off), 9.3.0 and 9.2.0 with the minimal test; the study's runs on mainea57360912edanda9779ef3c193(the files involved are identical) and on 8.0.0, 8.11.4, 9.2.0, 9.3.0, 9.12.3, 10.0.0 and 10.5.2; the recommended fix in a scratch worktree of645d4fe14a20. Upstream main2f424fd428d(2026-10-30) andbranch_10_6still have the line (read 2026-10-31).
- Finding record
lucene/core/src/java/org/apache/lucene/store/ByteBuffersDataOutput.java:311-332 (copyBytes override; :313 'assert numBytes >= 0', :314 'int length = (int) numBytes', :329-331 'if (length > 0) super.copyBytes(input, length)'), :285-298 (toArrayCopy javadoc and RuntimeException above the array limit), :349 (long size())lucene/core/src/java/org/apache/lucene/store/ByteBuffersIndexOutput.java:139-143 (copyBytes delegates)lucene/core/src/java/org/apache/lucene/store/DataOutput.java:280-293 (copyBytes javadoc 'Copy numBytes bytes from input to ourself.' and the long-safe base loop)lucene/core/src/java/org/apache/lucene/store/Directory.java:174-180 (copyFrom javadoc 'Copies an existing src file'), :189-197 (copyThroughCreateOutput, os.copyBytes(is, is.length()) at :193); lucene/core/src/java/org/apache/lucene/store/BaseDirectory.java:54-58 (copyFrom -> copyThroughCreateOutput)lucene/core/src/java/org/apache/lucene/store/ByteBuffersDirectory.java:45-50 (javadoc 'ephemeral, small, short-lived indexes', @lucene.experimental at :48, extends BaseDirectory at :50)lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:3648 (copySegmentAsIs), :3692 (directory.copyFrom)lucene/core/src/java/org/apache/lucene/codecs/lucene103/blocktree/TrieBuilder.java:82 (buffer is a ByteBuffersDataOutput), :153-156 (buffer.copyBytes(otherIn, otherIn.length())) and Lucene103BlockTreeTermsWriter.java:488 (trieBuilder.append(subIndex))a Lucene clone release tags (git show, 2026-10-26): TrieBuilder.java contains 'buffer.copyBytes(otherIn' at releases/lucene/10.5.0 and 10.5.2, not at 10.4.0 or 10.3.0
- The finding in 30 seconds
lucene/core/src/java/org/apache/lucene/store/ByteBuffersDataOutput.java:311-332 (the override; :313 assert, :314 narrowing, :315 loop, :329-331 fallback)lucene/core/src/java/org/apache/lucene/store/DataOutput.java:280-293 (contract and long-safe base loop)lucene/core/src/java/org/apache/lucene/store/ByteBuffersIndexOutput.java:139-143; store/BaseDirectory.java:54-58; store/Directory.java:174-197; store/ByteBuffersDirectory.java:50; index/IndexWriter.java:3103, 3648, 3692lucene (grep 'void copyBytes(DataInput' outside src/test: DataOutput, ByteBuffersDataOutput, ByteBuffersIndexOutput, EndiannessReverserDataOutput, and test-framework MockIndexOutputWrapper)lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:958, 3174-3175; store/LockValidatingDirectoryWrapper.java:47-51; store/FilterDirectory.java:126-128; store/FileSwitchDirectory.java:241-244; store/NRTCachingDirectory.java:65, 72-84, 155-165, 231-245 (wrapped destinations; code reading, not run)
- Who is affected
lucene/core/src/java/org/apache/lucene/store/ByteBuffersDirectory.java:39-50 (javadoc, @lucene.experimental); lucene/MIGRATE.md:1356-1360lucene/core/src/java/org/apache/lucene/codecs/lucene103/blocktree/TrieBuilder.java:82, 153-156
- Minimal repro
build-tools/build-infra/src/main/java/org/apache/lucene/gradle/plugins/java/TestsAndRandomizationPlugin.java:132 (tests.heapsize default 512m); lucene/core/src/test/org/apache/lucene/index/Test2BDocs.java:38 (@Monster)
- Why it looks unintended
lucene/core/src/java/org/apache/lucene/store/DataOutput.java:280-293; store/Directory.java:174-175; store/ByteBuffersDataOutput.java:285-298, 349lucene/core/src/test/org/apache/lucene/store/TestByteBuffersDataOutput.java:201-233lucene/CHANGES.txt:6908, 6922, 6930, 6933 (LUCENE-8624/8625 under Lucene 7.7.0 Bug fixes); GitHub #9674 (legacy-jira-resolution:Duplicate)lucene/test-framework/src/java/org/apache/lucene/tests/store/BaseDirectoryTestCase.java:799-837 (testCopyBytes: file up to 1,777,777 bytes, copyBytes chunks up to 77,777); lucene/core/src/test/org/apache/lucene/store/TestByteBuffersDirectory.java:34
- Suggested fix
lucene/core/src/java/org/apache/lucene/store/ByteBuffersDirectory.java:51, 64-81, 111, 259-266; store/ByteBuffersDataOutput.java:285-298 (OUTPUT_AS_ONE_BUFFER converts with toArrayCopy on close; code reading)
- History
a Lucene clone releases/lucene/9.2.0, 9.3.0, 10.5.2, 10.6.0: ByteBuffersDataOutput.java (narrowing line absent at 9.2.0, present at 9.3.0, 10.5.2, 10.6.0; copyBytes body at 9.3.0 and 10.6.0 identical to main)
- TestByteBuffersDataOutputCopyBytes2GiB.java (1 test):
repro/LFV-107/src/org/apache/lucene/store/TestByteBuffersDataOutputCopyBytes2GiB.java(28 lines)
Made separately from this text, from the code at 645d4fe14a20 and a probe executed for the diagram (a sparse 2,147,483,655-byte file copied with ByteBuffersDirectory.copyFrom, and direct copyBytes calls of several lengths, with assertions on and off, stock and with the fix); the PNG is the summary still.
assets/LFV-107/bug.png(443 KB)
