DirectIODirectory reads a file through a 256 KiB buffer and keeps that buffer's file offset in filePos. When a read on an input throws, fill() has already moved filePos to the next block while the buffer still holds the old one, and later seeks and reads trust that mismatch.
fill() leaves the window address filePos on the next block while the buffer still holds the previous one, so that a later seek or read returns the old block's bytes. Select the still to open it at full size. Generated with AI, checked against the code.- Open a 262,240-byte file through
DirectIODirectory, which reads in windows of 262,144 bytes (256 KiB). The file stores its own position as an int at every multiple of 4, soreadInt()shows where the bytes really came from. - Read the first 262,144 bytes, then ask for 200 more. Only 96 are left, so
readBytesthrowsEOFException, as it should. - Now call
seek(262148)andreadInt(). You expect 262,148, whichFSDirectory.open(MMapDirectory) returns. You get 4: the int stored 262,144 bytes earlier, andgetFilePointer()still says 262,148. - Why: before its EOF check threw,
fill()had already moved the window addressfilePosto the next block, while the buffer still holds the first block.seekfinds 262,148 inside this phantom window and serves it from the old bytes. - A failed disk read does the same: the next
readInt()returns 0 at file pointer 262,144. Default settings suffice: a stockDirectIODirectorymerge input of 10 MiB fails the same way.
Applications that opt in to the experimental DirectIODirectory (by default it serves merge reads of files of at least 10 MiB) and whose own code keeps using an input after a read on it threw: a retry after a transient I/O error, or a read after an EOFException. Lucene's own code stops at the first exception.
Mechanism, terms and numbers787 words
DirectIODirectory (lucene/misc) reads a file through a 256 KiB buffer window. Its DirectIOIndexInput.fill() first moves the window address filePos to the next block, then checks for EOF, then clears the buffer and reads the channel. When the EOF check throws (a read asked for more bytes than are left) or the channel read throws (a storage error, a closed channel, an interrupt), nothing is rolled back: filePos names the next block while the buffer keeps the previous block's bytes (or nothing yet, on a fresh input). getFilePointer(), seek()'s in-buffer shortcut and the read fast paths all trust that pairing, so the next seek or read on the same input silently returns bytes from exactly one window (262,144 bytes) away, or zeros, or throws EOFException at a legal position. In the repro, after a read past EOF on a 262,240-byte file, seek(262148) and readInt() return 4 instead of 262,148. It is in every release since 9.0.0 (the class's first) and on main; 10.2.0 widened it. Lucene's own code stops at the first exception, so it takes application code that keeps using the input.
DirectIODirectory: an opt-inDirectoryin lucene/misc that reads and writes withO_DIRECT, bypassing the OS page cache. By default it does so only in a merge context when the merge's estimated size (if known) and, for reads, the file are at least 10 MiB; everything else goes to the wrappedFSDirectory.- Window: each
DirectIOIndexInputreads through its own direct buffer of 262,144 bytes (DEFAULT_MERGE_BUFFER_SIZE).filePosis the file offset of the buffer's first byte, andgetFilePointer()isfilePos + buffer.position() - offset(DirectIODirectory.java:388).
fill()(:496-516) doesfilePos += buffer.capacity()first (:497). Its comment says it throwsEOFExceptionearly "to maintain buffer state (position in particular)" (:499-500), but the check (:501-503) runs after the move. Thenbuffer.clear()(:505) andchannel.read(buffer, filePos)(:510); a failed read is rethrown with nothing restored (:511-513).- After an EOF throw, the buffer still holds the previous block with its old limit (on a fresh input it is still empty). After a failed read, it is cleared (position 0, limit 262,144) but its memory still holds the previous block.
seek()does nothing whenpos == getFilePointer()(:403) and repositions inside the buffer when the target lies in[filePos, filePos + limit](:413-416);readByteandreadBytesserve straight from the buffer while it has bytes,readShort/Int/Longwhile it has at least 2, 4 or 8 (:444-477,:519-533).
Every file stores its own position as an int at each multiple of 4.
| trigger | expected (FSDirectory.open) | DirectIODirectory |
|---|---|---|
B: 524,304-byte file, readBytes of 786,432 throws, seek(0), readInt() | 0 | 262,144 (the second window) |
C: 4-byte file, readBytes of 8 throws, seek(0), readInt() | 0 | EOFException at legal position 0 |
D: 262,240-byte file, read 262,144, readBytes of 200 throws (96 left), seek(262148), readInt() | 262,148 | 4 |
D with the stock policy: new DirectIODirectory(FSDirectory.open(p)), merge context, 10,485,856-byte file, the same steps in the last window | 10,485,764 | 10,223,620 |
A: 524,288-byte file, read 262,144, one channel read fails (the channel stays open), readInt() again at file pointer 262,144 | 262,144 or an exception | 0 |
B without the seek: the next readInt() | 0 at file pointer 0 | 262,144 at file pointer 0 |
D without the seek: getFilePointer(), then readInt() | 262,144, then 262,144 | 524,288 (the file has 262,240 bytes), then EOFException with 96 bytes left |
Step by step for D: after window 0, filePos is 0 and the buffer holds bytes 0-262,143. fill(200) moves filePos to 262,144 and throws, so getFilePointer() becomes 524,288. seek(262148) finds 262,148 in [262,144, 524,288] and sets position 4 without reading; 262,140 bytes remain, so readInt() returns bytes 4-7 of the old window. For B, the throwing first fill leaves filePos at 0 with an empty buffer, so seek(0) is a no-op and the next fill moves on to the second window.
Also executed (the 7-trigger history test): the same as D on a slice, a clone read after the original was closed, and a read after a read interrupted by Thread.interrupt (the last two are close to misuse). Run for this doc: a seek whose channel read fails (seek(270340) on a 524,288-byte file after window 0) leaves getFilePointer() at the block start 270,336 while the next readInt() returns 0 and the one after it 4, both from window 0. The wrong bytes always come from the same input's own buffer: one window away in the same physical file, bytes just outside a slice's range (shown after a seek past the slice's end), or zeros.
Exposure and likelihood286 words
- Who is hit. Users of
DirectIODirectory(@lucene.experimental,DirectIODirectory.java:53-61). By default it uses direct I/O for reads in a merge context when the merge's estimated size (if known) and the file's length are both at leastminBytesDirect, 10 MiB by default (useDirectIO,:168-173;:72) and for every read an application routes to it by overridinguseDirectIO. Within the class, default settings suffice: the stock-policy row of the table above. - When. Code that keeps using the same input after a read on it threw: a retry after a transient storage error, a read after probing with a too-large read, a custom codec or file tool that catches
EOFExceptionand goes on. It then silently gets bytes of another position whilegetFilePointer()reports the right one, or a spuriousEOFException. - Not in Lucene's own paths. Merges abort on the first exception;
SegmentInfos(:314) andSegmentCoreReaders(:141) turnEOFExceptionintoCorruptIndexException;CodecUtil.checkFooter(in, priorException)reads on after an exception, but on checksum inputs, which open withIOContext.READONCE(Directory.java:155-156) and so go to the delegate. No in-tree wrong result was shown. - Scale. None needed: one exception, then one read. Wrong bytes appear only on the input whose read threw: in the security probe (state provoked by a seek past the end), clones taken before and after the failure and a new slice read the right bytes. All views of a file share one
FileChannel(:354), so after a close or an interrupt none of them can read new blocks. - Versions. Every release from 9.0.0 to 10.6.0 (10.6.0 run at its RC1 revision) and main; 10.2.0 added the legal-seek trigger and the closed-channel triggers.
The test shows that after a readBytes past EOF on a DirectIODirectory input, a legal seek lands in a stale buffer (fill() moved filePos before its EOF check threw), so the next read silently returns a byte of another file position.
/*
* 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.misc.store;
import java.io.EOFException;
import java.nio.file.Files;
import java.nio.file.Path;
import org.apache.lucene.store.Directory;
import org.apache.lucene.store.FSDirectory;
import org.apache.lucene.store.IOContext;
import org.apache.lucene.store.IndexInput;
import org.apache.lucene.store.IndexOutput;
import org.apache.lucene.tests.util.LuceneTestCase;
public class TestDirectIOSeekAfterReadPastEOF extends LuceneTestCase {
public void testLegalSeekAfterReadPastEOF() throws Exception {
Path path = createTempDir();
int block = Math.toIntExact(Files.getFileStore(path).getBlockSize());
try (Directory dir = new DirectIODirectory(FSDirectory.open(path), block, 0)) {
try (IndexOutput out = dir.createOutput("f", IOContext.DEFAULT)) {
for (int i = 0; i < block + 4; i++) {
out.writeByte((byte) (i % 251));
}
}
try (IndexInput in = dir.openInput("f", IOContext.merge())) {
in.seek(block - 1);
expectThrows(EOFException.class, () -> in.readBytes(new byte[17], 0, 17));
in.seek(block + 1);
// expected: the byte at position block + 1; actual: the byte at position 1 (stale first block)
assertEquals((byte) ((block + 1) % 251), in.readByte());
}
}
}
}
# in a copy of this finding's repro folder (repro/LFV-504/, which holds src/)
# Lucene checkout at the tested commit, built with: ./gradlew --no-daemon assemble (JDK 25)
LUCENE=/path/to/lucene; V=12.0.0-SNAPSHOT
CP=$LUCENE/lucene/core/build/libs/lucene-core-$V.jar
CP=$CP:$LUCENE/lucene/codecs/build/libs/lucene-codecs-$V.jar
CP=$CP:$LUCENE/lucene/test-framework/build/libs/lucene-test-framework-$V.jar
CP=$CP:$LUCENE/lucene/misc/build/libs/lucene-misc-$V.jar
CP=$CP:randomizedtesting-runner-2.9.2.jar:junit-4.13.2.jar:hamcrest-3.0.jar # test-framework dependencies (Maven Central)
CP=$CP:junit-platform-console-standalone-6.1.3.jar # JUnit Platform console launcher (Maven Central)
javac -d classes -cp "$CP" $(find src -name '*.java')
# run 1: assertions on, seed DEADBEEF
java -ea -esa --add-modules jdk.incubator.vector --enable-native-access=ALL-UNNAMED -Dtests.seed=DEADBEEF \
-cp "classes:$CP" org.junit.platform.console.ConsoleLauncher \
execute --select-class org.apache.lucene.misc.store.TestDirectIOSeekAfterReadPastEOF
- Expected vs actual
- Expected:
readByte()afterseek(block + 1)returns the byte at position block + 1 (81 for 4096-byte blocks). Actual:expected:<81> but was:<1>, the byte at position 1.
More files, environment, full output34 words
| lucene | Lucene main e1faeba08bde (2026-11-02) |
|---|---|
| jdk | 25.0.3+9-LTS |
# Lucene main 645d4fe14a20, assertions on (-ea), seed DEADBEEF: 1 test(s) run, 1 failed
=> java.lang.AssertionError: expected:<81> but was:<1>
org.junit.Assert.fail(Assert.java:89)
org.junit.Assert.failNotEquals(Assert.java:835)
org.junit.Assert.assertEquals(Assert.java:647)
org.junit.Assert.assertEquals(Assert.java:633)
org.apache.lucene.misc.store.TestDirectIOSeekAfterReadPastEOF.testLegalSeekAfterReadPastEOF(TestDirectIOSeekAfterReadPastEOF.java:45)getFilePointer()is "where the next read will occur"; the main triggers never seek past the end, the one case the javadoc leaves "undetermined".fill()'s comment says it throwsEOFExceptionearly "to maintain buffer state"; in a 2021 review the author saidfilePoskeeps the state consistent after EOF.- The same bug class was fixed in
BufferedIndexInput(LUCENE-1262) andRAMInputStream(LUCENE-2852).
The full argument448 words
IndexInput#getFilePointer(IndexInput.java:61-66): "Returns the current position in this file, where the next read will occur."IndexInput#seek(:68-75): "Sets current position in this file, where the next read will occur. If this is beyond the end of the file then this will throwEOFExceptionand then the stream is in an undetermined state." Triggers A, B, C and D never seek past the end.DataInput#readBytes(DataInput.java:45-52) and the other read methods say nothing about the state after they throw, so the argument rests on what follows: in B, C and D a legalseek, after which the javadoc says where the next read occurs; in A,getFilePointer()naming a position whose bytes are not the ones returned.fill()'s comment (DirectIODirectory.java:499-500): it throwsEOFExceptionearly "to maintain buffer state (position in particular)", citingBaseDirectoryTestCase#testSeekPastEOF(consecutive reads past EOF). The early throw does keep the buffer's position, but thefilePosadvance before it, which keeps reads after a seek past EOF throwing (testSeekPastEOFAndRead), also runs on the read-past-EOF and failed-read paths, where it putsfilePosout of step with the buffer.IndexInputclass javadoc (:37-38): reads on a clone after the original is closed "will throwAlreadyClosedException"; the clone trigger returns stale bytes instead.- 2021 review of LUCENE-9686 (apache/lucene-solr#2258), a review of the post-EOF semantics. dweiss: "I wonder if we should move the channel's position to actually point after the last byte, then throw EOFException... so that not only we indicate an EOF but also leave the channel pointing at the end. I have a scenario in my mind when somebody tries to read a bulk of bytes, hits an eof but then a single-byte read() succeeds. That would be awkward, wouldn't it?", and in the PR conversation "I have some doubts whether EOF should leave the channel undrained." zacharymorn answered that the class "already handles that actually (by manipulating
filePos, but notchannel.positionper se)" and added seek-past-EOF tests; dweiss: "Ok. Thanks for explaining!". The reviewer preferred drain-to-end (the next read fails); the exchange settled on "filePoskeeps the state consistent", and nobody noticed thatfilePosthen stops matching the buffer. On main the case dweiss called awkward does happen, with wrong bytes (B without the seek). The recommended fix keeps the other semantics (see Fixes). - Precedents: LUCENE-1262 (#2339, 2008), stale bytes in
BufferedIndexInputafter an exception inreadInternal, and LUCENE-2852 (2011),RAMInputStream"hits false EOF if you seek to EOF then seek back then readBytes", both fixed as bugs. - The reference, MMapDirectory, returns the right bytes for B, C, D and the stock-policy steps; A has no reference run.
The devil's-advocate check264 words
The strongest case for "intended": the class is @lucene.experimental and warns it "quite easily could contain horrible bugs ... Use at your own risk" (DirectIODirectory.java:53-56); no read method promises a usable state after it throws, and IndexInput#seek leaves the state undetermined after a seek past the end; merges abort on the first exception, so nobody reads on; the filePos advance before the EOF check is load-bearing (it keeps reads after a seek past EOF throwing, and Elasticsearch's copy keeps it too); the 2021 review accepted the post-EOF handling; and #16619, whose description keeps "the behaviour identical to today, quirks included", added "a characterization test, not an endorsement" of a different post-EOF quirk (TestDirectIODirectory.java:380-387).
Why it fails: "experimental" lowers priority but documents no wrong bytes; the main triggers involve no seek past the end and end with a legal seek whose result the javadoc defines (the finder's two seek-past-EOF tests were dropped for that reason); the fill() comment shows the intent to keep the buffer state, and the 2021 review accepted the class on the answer that filePos keeps the state consistent, which is what fails here; what the advance does for a seek past EOF (later reads keep throwing) can be kept while the read paths stop desynchronizing (the recommended fix does both); LUCENE-1262 and LUCENE-2852 were treated as bugs; and the #16619 quirk (a read past a slice's own length served from the same window) is a different behaviour that the fix keeps. The adjudication rated the case for intended behaviour as failing; the cross-model reviewers rated it moderate.
Recommended Keep filePos matching the buffer when a fill fails
In fill(), check EOF before moving filePos; after a failed channel read, empty the buffer and read the same block again next time; a seek past EOF leaves the input at its end (+48/-11 in DirectIODirectory.java). The repro and TestDirectIODirectory (69 tests) pass with assertions on and off, and so does the whole misc module.
All options and trade-offs4 options · 1,094 words
One fix was built and validated with gradle in a scratch worktree at a9779ef3c193; the finder's earlier sketch was validated too and has two gaps. The other options are untested.
DirectIODirectory.java only, +48/-11. Three parts: (1) fill() checks EOF against the next block before changing any state, and after a failed channel read empties the buffer (limit(0)) and sets a flag so that the next fill reads the same block again; (2) seekInternal(), on EOFException (only a seek past the end gets there), leaves the input at its end with an empty buffer, so later reads keep throwing EOFException and no later seek lands in the old window; (3) fillPending(), the lazy first fill of a clone or slice (#16619), restores the pending start if its read fails. The excerpt below is condensed (the diff keeps and extends the original comments; the logic is the same).
private void fill(int bytesToRead) throws IOException {
// the next block, or the same block again after its read failed
final long nextFilePos = refillSameBlock ? filePos : filePos + buffer.capacity();
// throw EOFException before any state changes: filePos must keep matching the buffer
if (nextFilePos > offset + length || ((offset + length) - nextFilePos < bytesToRead)) {
throw new EOFException("read past EOF: " + this);
}
filePos = nextFilePos;
refillSameBlock = false;
buffer.clear();
try {
channel.read(buffer, filePos);
} catch (IOException ioe) {
buffer.limit(0); // nothing valid is buffered: read this block again next time
refillSameBlock = true;
throw new IOException(ioe.getMessage() + ": " + this, ioe);
}
buffer.flip();
}Trade-offs
- Only the state after an exception changes; success paths do one extra boolean test per 256 KiB fill, and
readByteand the other read fast paths are untouched. No benchmark was run. - New, deterministic behaviour after a failure, in which
filePosalways matches the buffer. After a read past EOF,getFilePointer()is where the bytes the read consumed end and the next read continues there with the right bytes: in B, C and D where the failed read started, as with MMapDirectory; if the buffer still had bytes,readBytescopies them out first, so the pointer ends at the window's end. After a failed channel read the next read retries the same block; after a seek past EOFgetFilePointer()equalslength(). The read is not made atomic. - Its post-EOF semantics are not the drain-to-end ones dweiss suggested in the 2021 review: a single-byte read after a failed bulk read succeeds, with the right bytes. A drain-to-end variant (on a read past EOF, set
filePosto the end and empty the buffer, as part (2) does for a seek past EOF) would, by reading, also fix B, C and D; untested. - A seek whose channel read fails leaves the input at the start of the target's block, not at the target:
getFilePointer()reports the block start, the next read returns the bytes there, and seeking again works (executed: 270,336 then 270,340; stock main returns 0 and 4). That probe belongs in the regression tests. - Neither stock nor this fix loops on a short
channel.read(theFileChannelcontract allows fewer bytes than requested); the next fill would then skip the unread bytes. A separate question, untested. - #16619's characterization tests still pass, so that quirk (a read past a slice's own length served from the window) is unchanged.
- A clone read after the original was closed now keeps throwing
IOException, still not theAlreadyClosedExceptionthatIndexInput's javadoc promises; that gap is unchanged. - Backport: branch_10x and branch_10_6 have
refill()with the same ordering but no lazy slice start, so they need parts (1) and (2) only (read). Add a CHANGES entry under Bug Fixes.
What the checks validated
- Clean scratch worktree at
a9779ef3c193, gradle. Stock: the repro (5), the 7-trigger history test and the 3-scenario probe fail 15 of 15. With the fix: all 16 pass (the probe now with 4 tests), assertions on and off, and every probe scenario that has a reference line matches it. - Closest suites with the fix:
TestDirectIODirectory(69 tests, 2 skipped by their own FSDirectory-only assumptions) andTestDirectIODirectoryCopyFrom(1), assertions on and off; the wholelucene/miscmodule, its 23 test classes (315 tests) plus the 3 evidence classes (16 tests): 331 tests, 0 failures, 9 skipped. checkGoogleJavaFormat,ecjLintMain,ecjLintTest,forbiddenApisMainandforbiddenApisTestoflucene/miscpass with the fix and the repro test in place. The worktree was removed afterwards.- The same class shadowed onto the stock jars, assertions on and off: the finder's 5 tests, the independent reproducer's 6 and the devil's-advocate read-past-EOF probe (5) pass; in the transient-error probe the DirectIO test passes and the NIOFS control still fails, which is the separate core bug LFV-492.
- The failed-seek probe passes with the fix, assertions on and off; the independent fact check re-ran the fix through gradle with fresh seeds (the repro,
TestDirectIODirectory,TestDirectIODirectoryCopyFrom: pass).
+40/-5: on a failed fill set the buffer's limit to 0 and an "invalid" flag that disables seek()'s shortcut; after a failed channel read, do not advance filePos again.
Trade-offs
It keeps filePos moved after a read past EOF, so the next read without a seek still skips a window: after B's failed read readInt() returns 262,144 at file pointer 0, and after D's it throws EOFException with 96 bytes left. After a seek past the end of slice(100, 4) of a 200-byte file, getFilePointer() fails its own assertion under -ea (and returns 0 under -da).
What the checks validated
The repro and history tests pass (12 of 12), and so do TestDirectIODirectory (69) and TestDirectIODirectoryCopyFrom (1); 2 of the 3 probe scenarios still differ from the reference.
A few lines.
Trade-offs
Fixes the read-past-EOF triggers B, C and D, but not the failed-read, closed-channel or interrupt triggers. By reading, it also breaks TestDirectIODirectory.testSeekPastEOFAndRead (a read after a seek past EOF must throw) unless seekInternal() handles the seek past EOF as in the recommended option. Untested.
Every later read and seek throws.
Trade-offs
Simple and safe. No contract promises that an input stays usable after a read throws, so poisoning after a low-level I/O failure is defensible; after an ordinary read past EOF it is stricter than MMapDirectory, which keeps reading the right bytes there. Untested.
Elasticsearch's own copy of this input, AsyncDirectIOIndexInput, also sets filePos to the next block before its EOF throw and clears the buffer before reading (by reading its code; not executed).
A differential test drove 29 IndexInput configurations over byte-identical files and compared each with a simple reference model (225.6 million operation sequences). Its only unexplained mismatches, 21, were all DirectIODirectory reads after an EOFException; reading fill() found the order of operations, and JUnit tests reproduced it on the stock jars.
The method in full135 words
Method: cross-implementation differential testing against an independent reference model of IndexInput (the hunt's "semi-formal" tier; no model checker, solver or prover found it).
- The harness drove 29
IndexInputconfigurations over byte-identical files with the same operation sequences: 225.6 million sequences and 559 million checks over those 29 configurations, with assertions on and off, on the stock main jars. - After the documented differences were classified, 21 mismatches were left, all post-EOF recovery on
DirectIODirectory: provoke anEOFException, seek back to a legal position, read, and get a wrong byte or a spuriousEOFException. Its close-semantics and interrupt phases showed the failed-read variant. - Reading
fill()located the ordering, and the finding was reduced to public-API JUnit tests. With the fix sketch the harness found 0 wrong-value and 0 spurious-exception mismatches; the documented-difference buckets remain.
No prior report found (2026-10-26, re-checked 2026-10-28) in Lucene's GitHub issues, PRs and commits, JIRA, the mailing lists, downstream projects or the web. Closest: a 2021 review of this method discussed which state a failed bulk read should leave and accepted that filePos keeps it consistent; it did not find the mismatch.
Searches4 searches
Verdict new (registry novelty): no report of the filePos/buffer mismatch by anyone. The 2021 review (lucene-solr#2258) discussed the post-EOF semantics and named the case B shows (a bulk read hits EOF, then a single-byte read succeeds), but as a design question, and accepted the answer that filePos keeps the state consistent; it did not find wrong bytes. The registry counts it as a close touch, not an overlap. The failed-read variants have no prior mention. Related, not reports: #14320 and #14103 (10.2.0, widened it), #16619 (moved refill's body into a new fill, kept the ordering), #10725 (a different bug in the same method), LUCENE-1262 and LUCENE-2852 (the same class of bug elsewhere), and LFV-492, the same shape in core BufferedIndexInput (a hunt finding with its own fix). Gaps: GitHub search does not cover the diffs of closed unmerged PRs; the JIRA archive ends in 2022.
| Where | When | How | Found |
|---|---|---|---|
| apache/lucene issues, PRs and commits; downstream repositories; dev@, java-user@, issues@ and commits@; JIRA archive; the web | 2026-10-26 | the hunt's prior-report tool, four runs (the finder's 51 queries; the EOF variant, 51 plus 2 web queries added by a later enrichment; the I/O-error and seek variants, 37 each): DirectIOIndexInput, filePos, read past EOF, seek after EOFException, IOException, interrupt, stale bytes, maintain buffer state, undetermined state, NativeUnixDirectory | no report; nearest the lucene-solr#2258 review (post-EOF semantics), #16619, #14320, #14103 |
| GitHub (apache/lucene, apache/lucene-solr, Elasticsearch, OpenSearch, Solr) | 2026-10-26 | 53 GraphQL and 10 REST searches; the changed files of all 408 open apache/lucene PRs | no report; only #14928 touches the file, not fill, seek or the EOF check |
| Mailing lists and threads | 2026-10-26 | 248 list messages for DirectIODirectory and DirectIOIndexInput fetched directly; the on-topic threads read in full | no report; a 2026 dev@ message restates the clone-after-close contract |
| GitHub issues and PRs (apache/lucene), items updated since 2026-10-25 | 2026-10-28 | DirectIOIndexInput; DirectIODirectory; "read past EOF"; filePos; "direct I/O" | nothing new (#16905 merge contexts, #16145 mmap prefetch); the file has not changed upstream since #16749; the fact check's saved list: 74 items, only #16905 matches |
Introduced with the class, LUCENE-8982 (a7747b63b4a, 2021-01-17: the failed-read form); LUCENE-9686 (3835cb4e95c, 2021-02-02) exposed the EOF form. Both are in 9.0.0. 10.2.0 widened it (#14320 added the in-buffer seek shortcut, #14103 the cached length). Not fixed on main.
Versions tested7 rows
Introduced by a7747b63b4a, LUCENE-8982 (apache/lucene-solr#2052, author zacharymorn, merged by uschindler, 2021-01-17), which created DirectIOIndexInput with refill() (whose body #16619 moved into a new fill()) doing filePos += buffer.capacity() before the EOF check and buffer.clear() before the read. 3835cb4e95c, LUCENE-9686 (#2258, 2021-02-02), added the bytesToRead clause that throws on a first fill after filePos moved (triggers B and C). Both are in 9.0.0.
Widened in 10.2.0 by #14320 (ChrisHegarty, 2025-03-03: the in-buffer seek shortcut, trigger D) and #14103 (ChrisHegarty, 2025-01-25: the cached length, so a closed channel reaches buffer.clear()). #16619 (main, 2026-09-03) moved refill's body into a new fill (the remaining refill handles a slice's lazy first fill) and kept the ordering. branch_10x and branch_10_6 have the same ordering (read). Not fixed upstream as of 20ced71c1e6e (2026-10-27).
| Version or commit | Verdict | Result |
|---|---|---|
main a9779ef3c193 | affected | this doc's repro 5 of 5 fail, assertions on and off, also through gradle; the 7-trigger history test 7 of 7 |
| 10.2.0, 10.5.2 | affected | repro 5 of 5 fail (assertions on); history test 7 of 7 (10.5.2 also with -da) |
| 9.0.0, 9.12.3, 10.1.0 | 3 of 5 | the transient read error and the two seek(0) cases fail (assertions on); the legal-seek trigger needs the 10.2.0 seek shortcut |
3835cb4e95c (LUCENE-9686) on 9.0.0 | bisect | triggers A, B and C fail (B and C pass at the file's previous commit a7747b63b4a) |
a7747b63b4a (LUCENE-8982) on 9.0.0 | bisect | trigger A (failed read) fails; B and C pass |
| 8.11.0, 8.11.4 | API absent | no DirectIODirectory (compile error) |
fix, scratch worktree at a9779ef3c193 | not affected | repro, history test and probe pass; TestDirectIODirectory and the misc module (315 tests plus the 16 evidence tests) pass |
The finder's tests, an independent reproducer, a devil's-advocate check and an adjudication found it real; a reviewer from another model family (OpenAI GPT-5.6), unable to run code, agreed on the root cause. This doc re-ran everything on main a9779ef3c193 and validated a fix with gradle.
Checks and verdicts351 words
The checks ran on main ea57360912ed unless stated; DirectIODirectory.java is the same at a9779ef3c193.
| Check | Verdict | What it found |
|---|---|---|
| finder | candidate | Differential-test signature; public-API JUnits fail 5 of 5 on main (assertions on and off) and 10.5.2; the fix sketch passes them and the upstream DirectIO suites. |
| cross-model review (refute lens) | not real (low), on procedure only | OpenAI GPT-5.6, read-only: it could not run Java, agreed with the root cause and found the inputs in contract; it narrowed the claim (the seek-past-EOF tests fall under "undetermined state"). |
| independent reproducer | real | Own tests fail 5 of 5 on main and 10.5.2, 1 of 5 on 9.0.0 to 10.1.0; its default-configuration test fails too. |
| devil's advocate | real | Built the strongest works-as-designed case and rejected it for the main triggers (LUCENE-1262, LUCENE-2852); its transient-error probe fails on 9.0.0, 9.12.3, 10.1.0, 10.5.2 and main. |
| adjudication | confirmed (high) | Re-ran the reproducer's tests on a fresh seed (5 of 5 fail, on and off); demoted the seek-past-EOF tests and corrected the version range. |
| security screen | not security-sensitive | No attacker-controlled trigger; wrong bytes only from the same input's own buffer; other clones and slices unaffected (executed probe). |
| this doc's runs | reproduced; fix validated | New repro 5 of 5 on main (on and off), 10.2.0 and 10.5.2, 3 of 5 on 9.0.0 to 10.1.0; the fix validated with gradle; the finder's sketch leaves 2 probe scenarios wrong. |
| fact checks of this doc | reproduced; corrections applied | An independent fact check re-ran the doc's gradle command (5 of 5 fail, assertions on and off), the standalone runner on 9.0.0, 10.1.0, 10.2.0, 10.5.2 and main, the probe, the stock-policy steps on FSDirectory.open and the fix through gradle with fresh seeds. A second one, by another model family (OpenAI GPT-5.6, read-only), re-read the sources and logs, and a diagram check compared every frame with the code. All found the bug as described; their corrections are applied (the 2021 review is now quoted in full and described as a review of post-EOF semantics), and a failed-seek probe was added. |
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 512 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,650 words
- Registry title
- DirectIOIndexInput.fill moves filePos to the next block, and clears the buffer, before its EOF check and before the channel read succeeds, so after a read that threw, a legal seek or the next read on the same input returns bytes of another file position or a spurious EOFException (since 9.0.0)
- Area
- misc / store: DirectIODirectory (DirectIOIndexInput.fill and seek)
- Priority
- Low, rank 387: Only code that keeps reading an experimental DirectIODirectory input after a read on it threw is hit; Lucene's own paths stop at the first exception.
- Impact class
- silent wrong results
- Impact note
- Only users of the opt-in, @lucene.experimental misc DirectIODirectory (by default it serves merge-context reads of files of at least 10 MB, or every read an application routes to it through useDirectIO), and only when code keeps using the same DirectIOIndexInput after a read on it threw (an EOFException from a read past the end, or an IOException from a transient storage error, a closed original or an interrupt): the next reads then silently return bytes of another position of the same file (from the previous or next buffer window, 256 KiB by default, or zeros) while getFilePointer() reports the requested position, or throw EOFException at a legal position; Lucene's own merge and read paths stop at the first exception, so no in-tree wrong result was shown; no scale needed; released 9.0.0 to 10.6.0 and main, with -ea and -da.
- Summary
- You read a file through the misc DirectIODirectory, one read on an input throws (an EOFException because it asked for more bytes than are left, or an IOException from a transient storage error), and you then seek back to a legal position, or simply read again, on the same input. You expect the bytes at the file pointer, or another exception (after the same read-past-EOF sequences MMapDirectory returns the right bytes). Instead fill() had already moved its window position (filePos) to the next block, and on the read path cleared the buffer, before it threw, so the next reads silently return bytes of a different position of the same file (or throw EOFException at a legal position) while getFilePointer() reports the requested one.
- Introduced
- a7747b63b4ac649837469ee189b8aef10e4ce867 on main (then 'master'), 'LUCENE-8982: Make NativeUnixDirectory pure java with FileChannel direct IO flag, and rename to DirectIODirectory (#2052)', 2021-01-17. It added DirectIOIndexInput with refill() (renamed fill() by #16619 in 2026) doing 'filePos += buffer.capacity()' before the EOF check and 'buffer.clear()' before 'channel.read(buffer, filePos)'; git blame at 7cecf795236 (same file as ea57360912ed) still attributes those lines to this commit. From this commit a failed channel read leaves the previous window's bytes under the next window's address (trigger [A], executed: fails at a7747b63b4a). The EOF-check form named by the key became observable 16 days later with 3835cb4e95ce6ba93ab5e3d5caa35001c90db30a ('LUCENE-9686: Fix read past EOF handling in DirectIODirectory (#2258)', 2021-02-02), which added the clause 'channel.size() - filePos < bytesToRead': that clause throws on a first fill after filePos was already moved to 0, so seek(0) becomes a no-op and the next read returns the next window (triggers [B] and [C], executed: pass at a7747b63b4a, fail at 3835cb4e95c). Both commits are in 9.0.0, the first release of the class. 10.2.0 widened the bug: #14103 (main 1cd77c2ca2a, branch_10x bd12e07b114, 2025-01-25) replaced channel.size() in the EOF check by the cached length, so a closed channel no longer throws before buffer.clear() (triggers [E] clone-after-close and [F] interrupt, executed: pass before, fail at bd12e07b114); #14320 (main f8fac44b321, branch_10x 212924e5000, 2025-03-03) added seek()'s in-buffer shortcut, which serves the stale window after a read past EOF (triggers [D] and [D-slice], executed: pass at bd12e07b114, fail at 212924e5000 = the 10.2.0 file). PRs: apache/lucene-solr#2052 (LUCENE-8982; author zacharymorn, merged by uschindler 2021-01-17, base master) introduced the class and the ordering. apache/lucene-solr#2258 (LUCENE-9686; author zacharymorn, merged by dweiss 2021-02-02, base master) added the bytesToRead EOF clause that exposes the EOF variant. Widened in 10.2.0 by apache/lucene#14103 (author and merger ChrisHegarty, 2025-01-25) and apache/lucene#14320 (author and merger ChrisHegarty, 2025-03-03). apache/lucene#16619 (author john-mlika, merged by thecoop 2026-09-03, main only) renamed refill to fill and kept the ordering. (2021-01-17 (LUCENE-8982, read-failure trigger); 2021-02-02 (LUCENE-9686, EOF triggers [B]/[C]); 2025-01-25 and 2025-03-03 (#14103 and #14320, triggers [E]/[F] and [D], first released in 10.2.0))
- First affected version
- 9.0.0 [exec], the first release that has DirectIODirectory: triggers [A] (transient read error), [B] (oversized first read, then seek(0)) and [C] (spurious EOFException on a 1-byte file) fail. Triggers [D]/[D-slice] (legal seek after a read past EOF) and [E]/[F] (closed channel) first fail in 10.2.0 [exec]. The API does not exist before 9.0.0: 8.11.0 and 8.11.4 fail to compile the repro only because DirectIODirectory is missing [exec].
- Affected versions
- Every release from 9.0.0 to 10.6.0 (10.6.0 [exec] on the RC1 snapshot jars). 9.0.0 to 10.1.x: triggers [A] [B] [C] [exec 9.0.0, 9.12.3 (-ea and -da), 10.1.0; read: from 3835cb4e95c to 10.1.0 DirectIODirectory.java changes only in comments, javadoc, one @SuppressWarnings and the IOContext record accessors in useDirectIO; DirectIOIndexInput's code is unchanged]. 10.2.0 to 10.5.2: all seven triggers [exec 10.2.0, 10.5.2 (-ea and -da); read: DirectIODirectory.java has no commit between 10.2.0 and 10.5.2]. 10.6.0 (released 2026-10-29; branch_10_6 RC1 4ae946e5a9b) and the unreleased branch_10x at 657014b3c57 have the same fill and seek code [read: DirectIODirectory.java unchanged since #16749 on both; exec 2026-10-29 on the RC1 snapshot jars, -ea and -da: history 7/7, finder 5/5, stock-policy 1/1 fail]; #16883 (branch_10x 1309534296e, branch_10_6 2763db11b0a, main ea57360912e) had merges re-open vector and stored-field data with the reader's own sequential context, so the stock policy stopped using direct I/O for them; #16905 (branch_10x 657014b3c57, branch_10_6 940768fb0cd, main 49483e82b35) opens them with IOContext.merge() again. main from a7747b63b4a to 645d4fe14a2 [exec at ea57360912ed and 645d4fe14a2, -ea and -da]. Not affected: 8.x and earlier (no DirectIODirectory).
- Status
- documented (brief, checks and a per-finding doc exist)
- Verification
- Confirmed by AI-run checks: an independent repro, a cross-model review that could only read the code (its low-confidence “not real” is procedural), an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
- Found by
- semi-formal; also reached by code reading
- Upstream and prior reports
- none found (2026-10-26, follow-up prior-report record verdict new): four scripted prior-report searches (the finder's 51 queries and the follow-up's EOF, I/O-error and seek variants with 53, 37 and 37 queries: GitHub issues, PRs and commits on apache/lucene, downstream repositories, the dev@, java-user@, issues@ and commits@ lists, the JIRA archive and the web; no same-bug hit), 53 GitHub GraphQL and 10 REST searches over apache/lucene, apache/lucene-solr, Elasticsearch, OpenSearch and Solr, the changed files of all 408 open apache/lucene PRs (only #14928 touches DirectIODirectory.java, and not fill, seek or the EOF check), 248 list messages fetched directly, and full reads of the on-topic threads. Not fixed upstream: main 645d4fe14a2 (2026-10-29) has the same file, and branch_10x 657014b3c57 and branch_10_6 4ae946e5a9b keep the same ordering in refill(). Related, not reports of this bug: lucene-solr#2258 (LUCENE-9686, 2021) review on refill(), where dweiss asked whether the input stays consistent after an EOFException and zacharymorn answered that filePos handles it (nobody found that filePos then no longer matches the buffer); #16619 (2026-09-03, main only, pins a different slice quirk 'quirks included'); #14320 and #14103 (10.2.0, added the in-window seek shortcut and the cached length that widen this bug, no discussion of exception state); #10725 (LUCENE-9686, a different bug in the same method); #2339 (LUCENE-1262, 2008) and LUCENE-2852 (2011), the same bug class fixed as bugs in BufferedIndexInput and RAMInputStream; Elasticsearch #158421 / #158429 (a downstream copy, AsyncDirectIOIndexInput, where an interrupt closes the shared channel; no stale bytes mentioned), and that copy's current code appears to keep the same ordering [read only, not executed].
- Lucene commit tested
- main
a9779ef3c193(2026-10-26) with assertions on and off, through gradle in a clean scratch worktree and through a standalone JUnit runner on the built jars; the fix in the same worktree (the repro, the 7-trigger history test, a probe,TestDirectIODirectory, the whole misc module and the format and lint checks); this doc's repro also on the release jars 9.0.0, 9.12.3, 10.1.0, 10.2.0 and 10.5.2. Earlier runs: mainea57360912ed(the sameDirectIODirectory.java) and the commit-level history shadows. Upstream main20ced71c1e6e(2026-10-27) has the same file (read).
- Finding record
lucene/misc/src/java/org/apache/lucene/misc/store/DirectIODirectory.java:53-61 (at ea57360912ed) (WARNING 'could contain horrible bugs', 'Use at your own risk', @lucene.experimental), :168-183 (useDirectIO default: MERGE context, at least 10 MB), :402-422 (seek; in-window shortcut at :413-416), :444-450 (readByte serves buffer.get() while hasRemaining), :496-516 (fill: filePos += buffer.capacity() at :497, comment 'throwing EOFException early to maintain buffer state (position in particular)' at :499-500, EOF check at :501-503, buffer.clear() at :505, channel.read at :510, buffer.flip() at :515)lucene/core/src/java/org/apache/lucene/store/IndexInput.java:37-38 (at ea57360912ed) (reads on a clone after the original is closed throw AlreadyClosedException), :60-73 (getFilePointer and seek: 'where the next read will occur'; the 'undetermined state' clause covers only a seek beyond the end)
- The finding in 30 seconds
a Lucene main checkout lucene/misc/src/java/org/apache/lucene/misc/store/DirectIODirectory.java:304-344, 352, 372-376, 386-399, 401-436, 443-494, 496-516, 519-533
- Who is affected
a Lucene main checkout lucene/misc/src/java/org/apache/lucene/misc/store/DirectIODirectory.java:53-61, 168-183a Lucene main checkout lucene/core/src/java/org/apache/lucene/index/SegmentInfos.java:312-316; index/SegmentCoreReaders.java:139-143; store/Directory.java:155-156
- Why it looks unintended
a Lucene main checkout lucene/core/src/java/org/apache/lucene/store/IndexInput.java:37-38, 61-75; DataInput.java:45-52a Lucene main checkout lucene/misc/src/java/org/apache/lucene/misc/store/DirectIODirectory.java:53-56, 499-500a Lucene main checkout lucene/misc/src/test/org/apache/lucene/misc/store/TestDirectIODirectory.java:148-166 (testSeekPastEOFAndRead), 380-387
- Suggested fix
a Lucene main checkout lucene/misc/src/test/org/apache/lucene/misc/store/TestDirectIODirectory.java:148-166 (testSeekPastEOFAndRead)
- TestDirectIOSeekAfterReadPastEOF.java (JUnit test):
repro/LFV-504/src/org/apache/lucene/misc/store/TestDirectIOSeekAfterReadPastEOF.java(49 lines)
Made in parallel from an executed probe on main a9779ef3c193 that records filePos, the buffer's position and limit and its first bytes after each step of the read-past-EOF trigger on a 262,244-byte file, the transient read error and the stock-policy case. The fix line of the summary was re-run with the recommended fix shadowed.
assets/LFV-504/bug.png(512 KB)
