All findings · Lucene finding · LFV-504 · directio-fill-advances-filepos-before-eof-check

DirectIODirectory: after a read throws, the next seek or read on the same input can return bytes of another file position

After a read on a DirectIODirectory input throws (an EOFException or a failed disk read), the next seek or read on it can silently return bytes from 262,144 positions away while getFilePointer() reports the right position. In every release since 9.0.0, for code that keeps using the input.

Summary

Priority
Low, rank 387Only 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
silent wrong results
Since
9.0.0, the class's first release (widened in 10.2.0)introduced 2021-01-17 · about 5 years
Affected
9.0.0 to 10.6.0 (10.6.0 run at its RC1 revision) and main; opt-in DirectIODirectory (lucene/misc, experimental)
Status
documented
Found by
a differential test of 29 IndexInput configurations against a reference model

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

The finding in 30 seconds

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.

Diagram in three panels. 1, the executed example: a 262,244-byte file; readBytes of 262,144 bytes succeeds, readBytes of 200 throws EOFException with 100 bytes left, seek(262147), then readByte() returns 3, the byte at position 3, instead of -25, because the buffer at filePos 262,144 still holds file bytes 0 to 262,143. 2, the lines in DirectIODirectory.java: fill() moves filePos first (line 497), the EOF check throws (501-503), or buffer.clear() and a failing channel.read (505, 510-512), seek() repositions inside the buffer without a read (413-416), readByte() returns the stale byte; and IndexInput.seek's contract. 3, when it happens, who is affected, and the recommended fix with its executed result (-25, and -28 after the failed read).
How a failed 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.
  1. 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, so readInt() shows where the bytes really came from.
  2. Read the first 262,144 bytes, then ask for 200 more. Only 96 are left, so readBytes throws EOFException, as it should.
  3. Now call seek(262148) and readInt(). You expect 262,148, which FSDirectory.open (MMapDirectory) returns. You get 4: the int stored 262,144 bytes earlier, and getFilePointer() still says 262,148.
  4. Why: before its EOF check threw, fill() had already moved the window address filePos to the next block, while the buffer still holds the first block. seek finds 262,148 inside this phantom window and serves it from the old bytes.
  5. A failed disk read does the same: the next readInt() returns 0 at file pointer 262,144. Default settings suffice: a stock DirectIODirectory merge input of 10 MiB fails the same way.

Who is affected

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.

Terms

  • DirectIODirectory: an opt-in Directory in lucene/misc that reads and writes with O_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 wrapped FSDirectory.
  • Window: each DirectIOIndexInput reads through its own direct buffer of 262,144 bytes (DEFAULT_MERGE_BUFFER_SIZE). filePos is the file offset of the buffer's first byte, and getFilePointer() is filePos + buffer.position() - offset (DirectIODirectory.java:388).

The bug

  1. fill() (:496-516) does filePos += buffer.capacity() first (:497). Its comment says it throws EOFException early "to maintain buffer state (position in particular)" (:499-500), but the check (:501-503) runs after the move. Then buffer.clear() (:505) and channel.read(buffer, filePos) (:510); a failed read is rethrown with nothing restored (:511-513).
  2. 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.
  3. seek() does nothing when pos == getFilePointer() (:403) and repositions inside the buffer when the target lies in [filePos, filePos + limit] (:413-416); readByte and readBytes serve straight from the buffer while it has bytes, readShort/Int/Long while it has at least 2, 4 or 8 (:444-477, :519-533).

The numbers (main a9779ef3c193)

Every file stores its own position as an int at each multiple of 4.

triggerexpected (FSDirectory.open)DirectIODirectory
B: 524,304-byte file, readBytes of 786,432 throws, seek(0), readInt()0262,144 (the second window)
C: 4-byte file, readBytes of 8 throws, seek(0), readInt()0EOFException at legal position 0
D: 262,240-byte file, read 262,144, readBytes of 200 throws (96 left), seek(262148), readInt()262,1484
D with the stock policy: new DirectIODirectory(FSDirectory.open(p)), merge context, 10,485,856-byte file, the same steps in the last window10,485,76410,223,620
A: 524,288-byte file, read 262,144, one channel read fails (the channel stays open), readInt() again at file pointer 262,144262,144 or an exception0
B without the seek: the next readInt()0 at file pointer 0262,144 at file pointer 0
D without the seek: getFilePointer(), then readInt()262,144, then 262,144524,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 least minBytesDirect, 10 MiB by default (useDirectIO, :168-173; :72) and for every read an application routes to it by overriding useDirectIO. 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 EOFException and goes on. It then silently gets bytes of another position while getFilePointer() reports the right one, or a spurious EOFException.
  • Not in Lucene's own paths. Merges abort on the first exception; SegmentInfos (:314) and SegmentCoreReaders (:141) turn EOFException into CorruptIndexException; CodecUtil.checkFooter(in, priorException) reads on after an exception, but on checksum inputs, which open with IOContext.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.

Minimal repro

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.

TestDirectIOSeekAfterReadPastEOF.java (JUnit test)49 lines
/*
 * Licensed to the Apache Software Foundation (ASF) under one or more
 * contributor license agreements.  See the NOTICE file distributed with
 * this work for additional information regarding copyright ownership.
 * The ASF licenses this file to You under the Apache License, Version 2.0
 * (the "License"); you may not use this file except in compliance with
 * the License.  You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package org.apache.lucene.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());
      }
    }
  }
}
Command
# 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() after seek(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.

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

More files, environment, full output34 words

Environment

luceneLucene main e1faeba08bde (2026-11-02)
jdk25.0.3+9-LTS

Observed output

# 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)

Why it looks unintended

  • 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 throws EOFException early "to maintain buffer state"; in a 2021 review the author said filePos keeps the state consistent after EOF.
  • The same bug class was fixed in BufferedIndexInput (LUCENE-1262) and RAMInputStream (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 throw EOFException and 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 legal seek, 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 throws EOFException early "to maintain buffer state (position in particular)", citing BaseDirectoryTestCase#testSeekPastEOF (consecutive reads past EOF). The early throw does keep the buffer's position, but the filePos advance 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 puts filePos out of step with the buffer.
  • IndexInput class javadoc (:37-38): reads on a clone after the original is closed "will throw AlreadyClosedException"; 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 not channel.position per 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 "filePos keeps the state consistent", and nobody noticed that filePos then 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 BufferedIndexInput after an exception in readInternal, 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.

Suggested fix

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.

Keep filePos matching the buffer when a fill fails (validated by the checks) (recommended)

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 readByte and the other read fast paths are untouched. No benchmark was run.
  • New, deterministic behaviour after a failure, in which filePos always 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, readBytes copies 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 EOF getFilePointer() equals length(). 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 filePos to 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 (the FileChannel contract 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 the AlreadyClosedException that IndexInput'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) and TestDirectIODirectoryCopyFrom (1), assertions on and off; the whole lucene/misc module, its 23 test classes (315 tests) plus the 3 evidence classes (16 tests): 331 tests, 0 failures, 9 skipped.
  • checkGoogleJavaFormat, ecjLintMain, ecjLintTest, forbiddenApisMain and forbiddenApisTest of lucene/misc pass 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).

The finder's sketch: mark the buffer invalid after any failed fill (validated by the checks)

+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.

Only move the EOF check before filePos += capacity

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.

Poison the input after any failed fill (not recommended)

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.

Downstream note

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).

How it was found

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 IndexInput configurations 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 an EOFException, seek back to a legal position, read, and get a wrong byte or a spurious EOFException. 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.

Prior reports, history and checks

Prior reports

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.

What was searched, when, and what turned up
WhereWhenHowFound
apache/lucene issues, PRs and commits; downstream repositories; dev@, java-user@, issues@ and commits@; JIRA archive; the web2026-10-26the 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, NativeUnixDirectoryno report; nearest the lucene-solr#2258 review (post-EOF semantics), #16619, #14320, #14103
GitHub (apache/lucene, apache/lucene-solr, Elasticsearch, OpenSearch, Solr)2026-10-2653 GraphQL and 10 REST searches; the changed files of all 408 open apache/lucene PRsno report; only #14928 touches the file, not fill, seek or the EOF check
Mailing lists and threads2026-10-26248 list messages for DirectIODirectory and DirectIOIndexInput fetched directly; the on-topic threads read in fullno report; a 2026 dev@ message restates the clone-after-close contract
GitHub issues and PRs (apache/lucene), items updated since 2026-10-252026-10-28DirectIOIndexInput; 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

History

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).

Versions and branches executed, with results
Version or commitVerdictResult
main a9779ef3c193 affectedthis 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 affectedrepro 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 5the 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 bisecttriggers A, B and C fail (B and C pass at the file's previous commit a7747b63b4a)
a7747b63b4a (LUCENE-8982) on 9.0.0 bisecttrigger A (failed read) fails; B and C pass
8.11.0, 8.11.4 API absentno DirectIODirectory (compile error)
fix, scratch worktree at a9779ef3c193 not affectedrepro, history test and probe pass; TestDirectIODirectory and the misc module (315 tests plus the 16 evidence tests) pass

Independent checks

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.

Each independent check and its verdict
CheckVerdictWhat it found
finder candidateDifferential-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 onlyOpenAI 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 realOwn 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 realBuilt 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-sensitiveNo 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 validatedNew 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 appliedAn 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.

Diagram file

The diagram as a still image, generated with AI and checked against the code. It is under 10 MB, the limit for a GitHub attachment.

Sources and provenance

Finding record, Lucene and upstream sources, repro files and diagram notes1,650 words

Finding record

LFV-504, snapshot of 2026-11-06

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: main ea57360912ed (the same DirectIODirectory.java) and the commit-level history shadows. Upstream main 20ced71c1e6e (2026-10-27) has the same file (read).

Lucene and upstream sources

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-183
  • a 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-52
  • a Lucene main checkout lucene/misc/src/java/org/apache/lucene/misc/store/DirectIODirectory.java:53-56, 499-500
  • a 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)

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

Repro files

About the diagrams

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.

Built 2026-11-06 from the hunt's record of this finding and its doc file. Lucene main under test in this hunt: ea57360912ed. Every listed finding was re-run on Lucene main e1faeba08bde on 2026-11-02.