A wrapped or default-sliced input checks its length when it reads, not when it seeks.
- An
IndexInputreads a file.seek(pos)moves the read position andgetFilePointer()reports it. Since 5.5.0 its javadoc says a position beyond the end throwsEOFExceptionat once. BufferedIndexInputis the public base class for inputs that read through a buffer (1,024 bytes by default). A subclass implements two hooks:readInternalfills the buffer,seekInternalmoves the underlying position.BufferedIndexInput.wrap(desc, in, offset, length)returns a view oflengthbytes of another input. The defaultslice()of aBufferedIndexInputsubclass returns the same kind of view.
seek(21) on a 20-byte wrapped input: outside the buffer, into the empty seekInternal, and back with the position at 21; the length check runs only at the next read, which throws EOFException. Select the still to open it at full size. Generated with AI, checked against the code.- Write a 30-byte file to a
ByteBuffersDirectory, open it and take its first 20 bytes withBufferedIndexInput.wrap("w", in, 0, 20). The wrapped input'slength()is 20. - Call
seek(21), one byte past the end. You expectEOFException:IndexInput#seeksays "If this is beyond the end of the file then this will throw EOFException", and the stock input and its own 20-byte slice both throw it. - Instead
seek(21)returns normally andgetFilePointer()reports 21 on a 20-byte input;skipBytes(21)does the same. Only the nextreadByte()throwsEOFException("read past EOF"). - Why:
BufferedIndexInput.seekhands every target outside its buffer to the subclass hookseekInternal(pos)(BufferedIndexInput.java:326-334), and the wrapper'sseekInternalis empty (:423). Nothing compares the target withlength()untilrefill()runs at the next read (:290-295). - The same class backs the default
slice()of anyBufferedIndexInputsubclass that does not override it, and, in 9.0 to 10.1.x, the slices of the opt-inDirectIODirectory. Every release from 5.5.0 to 10.6.0 is affected.
Only code that seeks or skips past the end of a BufferedIndexInput.wrap input (Apache Solr's block-cache input calls wrap() in its slice()), of a default slice() of a BufferedIndexInput subclass outside Lucene, or, in 9.0 to 10.1.x, of an opt-in DirectIODirectory slice. No default Directory is affected on main. The error comes one read late, and that read returns no data.
Mechanism, terms and numbers668 words
IndexInput#seek promises: "If this is beyond the end of the file then this will throw EOFException and then the stream is in an undetermined state", and skipBytes is "functionally equivalent" to that seek (IndexInput.java:69-91). The public static BufferedIndexInput.wrap(desc, in, offset, length) and the default BufferedIndexInput.slice(), which returns wrap(...), both return the private SlicedIndexInput. BufferedIndexInput.seek hands every target outside its buffer to seekInternal(pos), and SlicedIndexInput.seekInternal is empty, so seek(21) on a 20-byte input returns normally, getFilePointer() reports 21, and the error only comes at the next read. In normal use the core Directories' own inputs and slices (ByteBuffersDirectory, NIOFSDirectory, MMapDirectory) throw at the seek (an MMapDirectory input after a read has hit EOF is a separate bug, LFV-130), and NIOFSDirectory and RAFDirectory do it with exactly the check SlicedIndexInput lacks.
The empty method came with wrap() and the default slice() in LUCENE-4371 (4.9.0, 2014). It became a contract violation in 5.5.0, when LUCENE-6932 wrote the seek sentence and fixed four other inputs but not this one. Every release from 5.5.0 to 10.6.0 is affected. The reach is narrow: no default Directory on main produces this class; it is reached through direct wrap() calls, slices of BufferedIndexInput subclasses outside Lucene that keep the default slice(), and, in 9.0 to 10.1.x, slices of the opt-in DirectIODirectory. The first read after the accepted seek never returned data. A three-line check in SlicedIndexInput.seekInternal fixes it.
IndexInput: Lucene's read handle on a file.seek(pos)moves the read position,getFilePointer()reports it,length()is the size, andskipBytes(n)isseek(getFilePointer() + n)(IndexInput.java:86-91).BufferedIndexInput(core, public, abstract,BufferedIndexInput.java:25): the base class for inputs that read through a buffer. Subclasses implementreadInternal(fill the buffer from the current position) andseekInternal("Sets current position in this file, where the next readInternal will occur",:336-342). TheNIOFSDirectoryand miscRAFDirectoryinputs are subclasses.BufferedIndexInput.wrap(desc, in, offset, length)(public static,:371-379, javadoc: "in most cases ineffective, because it may double buffer!"): a buffered view oflengthbytes of another input, starting atoffset. Its class is the privateSlicedIndexInput(:382). The defaultslice()returns the same view (:354-357); the in-tree subclasses override it.
seek(pos)outside the buffer setsbufferStart = pos, empties the buffer and callsseekInternal(pos)(:326-334). It never comparesposwithlength(); that is left to the hook.SlicedIndexInput.seekInternalis empty:protected void seekInternal(long pos) {}(:422-423). Any target is accepted, andgetFilePointer(), which isbufferStart + buffer.position()(:321-322), returns it.- The first length check runs at the next read:
refill()finds no bytes left and throwsEOFException(:290-295);SlicedIndexInput.readInternalhas a second check (:413-416). For some targets more than 2^31 bytes past the end, theintcast at:294gives a value above the 1,024-byte buffer and the read fails with an uncheckedIllegalArgumentExceptionfrombuffer.limit(:303) instead (executed at 2^31+1 bytes past the end; at 2^32-10 or 2^32 bytes past the end the read still throwsEOFException, at:415or:295). skipBytesgoes throughseek, and clones, slices and nested wraps of a wrapped input areSlicedIndexInputs too, so all of them accept the same targets.- The in-tree subclasses that conform put exactly this check in their own
seekInternal(NIOFSDirectory.java:203-208, miscRAFDirectory.java:191-196:if (pos > length()) throw new EOFException(...)) and overrideslice().
30-byte file, wrap("w", in, 0, 20): seek(20) is legal; seek(21) returns normally and getFilePointer() is 21; skipBytes(21) on a fresh wrap also leaves it at 21; the next readByte() throws EOFException: read past EOF: ... [slice=w]. The stock ByteBuffersDirectory input (seek(31) on 30 bytes) and its own 20-byte slice (seek(21)) throw EOFException at the seek. The differential harness saw every past-end seek and skip accepted on the wrapper, its clones and its slices (144,211 sequences).
Exposure and likelihood444 words
- Who is hit. Code that calls the public static
BufferedIndexInput.wrap(...)directly, and clones and slices of such inputs. Downstream example: Apache Solr's block-cache inputCustomBufferedIndexInput, which extendsIndexInput(notBufferedIndexInput), returnsBufferedIndexInput.wrap(...)from its ownslice()(source read, not run). Slices of anyBufferedIndexInputsubclass outside Lucene that keeps the defaultslice(): they accept seeks past their end even when the subclass's ownseekrejects them (executed: a strict subclass's ownseek(101)throws, its 10-byte default slice acceptsseek(11)). - Also hit in 9.0.0 to 10.1.x: slices of the opt-in misc
DirectIODirectory, whoseslice()returnedwrap(...)(executed on 9.1.0, 9.12.3, 10.0.0 and 10.1.0; the slice class isBufferedIndexInput$SlicedIndexInput). 10.2.0 gave them their own class (#14103). - Not hit: every default setup on main. No in-tree Directory produces a
SlicedIndexInput, and nothing in-tree callswrap()(grep). FreshMMapDirectory,NIOFSDirectoryandByteBuffersDirectoryinputs and slices throw at the seek (differential harness and controls, on fresh inputs; LFV-130 is a separateMMapDirectoryexception after a read has hit EOF). - What happens. The illegal seek or skip succeeds and
getFilePointer()reports a position beyondlength(); the next read throwsEOFException, or, for some targets more than 2^31 bytes past the end,IllegalArgumentException. Code that checksgetFilePointer()after a seek, or relies on the seek to reject a bad offset, sees the error one call later, or never if it does not read. - What does not happen. The first read after the accepted seek never returned a byte: a probe of 56,550 accepted past-end seeks or skips and 82,650 reads per run (main with assertions on and off, 10.5.2), and 42,252 reads on
DirectIODirectoryslices of 9.12.3 and 10.1.0, found no byte from outside the input. Caveat from the same probe: a caller that catches that failure and reads again can, for some targets more than 2^31 bytes past the end, get zeros or bytes this input buffered earlier, never bytes from outside it; that stale-byte half is a separate, already trackedrefill()defect (LFV-492), and fix A removes this route to it. No Lucene code path was found that reads the same input again after this exception;CodecUtil.checkFooter(in, priorException)does keep using an input after an earlier exception, but for a position past the end it throwsCorruptIndexExceptionbefore it reads (CodecUtil.java:482). - Default vs opt-in, scale. Opt-in: an expert API (
wrap(), or a customBufferedIndexInputsubclass), or the opt-inDirectIODirectoryin 9.0 to 10.1.x. One byte past the end is enough; data size plays no part. Valid indexes never produce such a target. Likelihood: rare; no matching public report was found in the sources searched.
One JUnit test on the public API: wrap the first 20 bytes of a 30-byte ByteBuffersDirectory file with BufferedIndexInput.wrap and expect seek(21) to throw EOFException. Deterministic, single thread, about 2 s; the same with assertions on and off.
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.lucene.store;
import java.io.EOFException;
import java.io.IOException;
import org.apache.lucene.tests.util.LuceneTestCase;
public class TestBufferedIndexInputWrapSeekPastEOF extends LuceneTestCase {
public void testWrapSeekPastEOF() throws IOException {
try (Directory dir = new ByteBuffersDirectory()) {
try (IndexOutput out = dir.createOutput("f", IOContext.DEFAULT)) {
out.writeBytes(new byte[30], 30);
}
try (IndexInput in = dir.openInput("f", IOContext.DEFAULT)) {
IndexInput wrapped = BufferedIndexInput.wrap("w", in, 0, 20);
// IndexInput#seek: EOFException beyond the end; actual: seek(21) on 20 bytes succeeds
expectThrows(EOFException.class, () -> wrapped.seek(21));
}
}
}
}
cp $REPRO/TestBufferedIndexInputWrapSeekPastEOF.java $LUCENE/lucene/core/src/test/org/apache/lucene/store/
cd $LUCENE && ./gradlew :lucene:core:test --tests org.apache.lucene.store.TestBufferedIndexInputWrapSeekPastEOF -Ptests.seed=DEADBEEF
# assertions off: add -Ptests.asserts=false (same result)
- Expected
seek(21)throwsEOFException, asIndexInput#seekpromises for a position beyond the end and as theByteBuffersDirectoryinput and its own slice do.- Observed
seek(21)returns normally: "Expected exception EOFException but no exception was thrown", with assertions on and off.
More files, environment, full output266 words
TestBufferedIndexInputWrapSeekPastEOF writes 30 zero bytes to a ByteBuffersDirectory file, opens it, takes BufferedIndexInput.wrap("w", in, 0, 20) and asserts expectThrows(EOFException.class, () -> wrapped.seek(21)). The file is 30 bytes so the target 21 is inside the file but one byte past the 20-byte input. Public API only (wrap is public static); the package is org.apache.lucene.store only because that is where an upstream test would live. No randomness of its own; seeds DEADBEEF and 5EED0480 (10 iterations) give the same failure.
As the regression test: this same test, for example as testWrapSeekPastEOF in TestBufferedIndexInput. With fix A it passes, and ./gradlew :lucene:core:check -x test passes with it as a new test file.
| lucene | main 645d4fe14a20 (11.0.0-SNAPSHOT); this test also fails on 10.6.0, 10.5.2 and 9.12.3; every release from 5.5.0 to 10.6.0 is affected |
|---|---|
| jdk | OpenJDK 25.0.3 (build 25.0.3+9-LTS) |
| os | Linux, aarch64 |
cp $REPRO/TestBufferedIndexInputWrapSeekPastEOF.java $LUCENE/lucene/core/src/test/org/apache/lucene/store/
cd $LUCENE && ./gradlew :lucene:core:test --tests org.apache.lucene.store.TestBufferedIndexInputWrapSeekPastEOF -Ptests.seed=DEADBEEF
# assertions off: add -Ptests.asserts=false (same result)
TestBufferedIndexInputWrapSeekPastEOF > testWrapSeekPastEOF FAILED
junit.framework.AssertionFailedError: Expected exception EOFException but no exception was thrown
at __randomizedtesting.SeedInfo.seed([DEADBEEF:56AA807DD014B8DF]:0)
at app//org.apache.lucene.tests.util.LuceneTestCaseParent.expectThrows(LuceneTestCaseParent.java:1432)
at app//org.apache.lucene.tests.util.LuceneTestCaseParent.expectThrows(LuceneTestCaseParent.java:1418)
at app//org.apache.lucene.store.TestBufferedIndexInputWrapSeekPastEOF.testWrapSeekPastEOF(TestBufferedIndexInputWrapSeekPastEOF.java:33)
(gradle, scratch worktree of 645d4fe14a20, seed DEADBEEF, assertions off; the same with assertions on)
What the input does after the accepted seek (a main() probe on the same input, not part of the repro):
wrapped class=org.apache.lucene.store.BufferedIndexInput$SlicedIndexInput length()=20
seek(20) ok, getFilePointer()=20
seek(21) accepted, getFilePointer()=21
readByte() after seek(21): EOFException: read past EOF: (clone of) ByteBuffersIndexInput (file=f, buffers=30 bytes, block size: 1, blocks: 1, position: 0) [slice=w]
skipBytes(21) accepted, getFilePointer()=21
stock ByteBuffersDirectory slice seek(21): EOFException
stock ByteBuffersDirectory input seek(31) on 30 bytes: EOFExceptionIndexInput#seek: "If this is beyond the end of the file then this will throwEOFException".- LUCENE-6932 (5.5.0) wrote that on purpose, for all implementations, and fixed four inputs; this one was missed.
- In normal use the core Directories' inputs and slices throw at the seek;
BaseDirectoryTestCase: "Seeking past EOF should always throw EOFException".
The full argument269 words
- Contract, this exact call.
IndexInput#seek: "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." (IndexInput.java:69-71).skipBytes: "Behavior is functionally equivalent to seeking togetFilePointer() + numBytes" (:80). Neitherwrap()norslice()documents an exception. - Written on purpose, for all implementations. LUCENE-6932 (#7990,
ddc65d977f92, 2016-01-21, 5.5.0) added that sentence and the API-change entry "IndexInput.seek implementations now throw EOFException if you seek beyond the end of the file" (lucene/CHANGES.txt:9726-9727). Its discussion chose to "throw EOFException immediately if you try to seek beyond the end of the file, rather than delaying it until the next read" (patch comment, 2016-01-21). It fixed the RAM, NIOFS, SimpleFS and RAF inputs; its 30 comments never mentionSlicedIndexInputorwrap. - Tests.
BaseDirectoryTestCase#testSeekPastEOF("Seeking past EOF should always throw EOFException",:731) and#testSeekBeyondEndOfFilerun against Directories; no in-tree Directory produces this class and no upstream test callswrap(), so it was never exercised. - Consistency. In normal use the core Directories' inputs and slices (
ByteBuffersDirectory,NIOFSDirectory,MMapDirectory) throw at the seek in the same differential harness, and the two in-treeBufferedIndexInputsubclasses implement the check in theirseekInternal. (Two separate, state- or class-specific exceptions are tracked on their own: anMMapDirectoryinput after a read hit EOF, LFV-130, and theDirectIOIndexInputslice slack pinned by #16619.) Executed 5.4.1 against 5.5.0: the wrap results are identical, only the stock controls switch from accepting to throwing.
The devil's-advocate check328 words
The strongest case for "not a bug" (verifiers and the adjudication built it): lazy EOF on the next read is how BufferedIndexInput always worked, and seekInternal's javadoc does not ask for a check; wrap() is discouraged ("in most cases ineffective, because it may double buffer!"; LUCENE-5681: "It is almost always a better idea to implement slicing on your own!") and a 2014 Won't Fix (#6890, LUCENE-5828) said "The slice wrapper is not used in any core directories, so maybe we should leave it as it is"; #16619 (2026-09) tolerates an analogous slack in DirectIOIndexInput, and one of its tests deliberately keeps a seek beyond a slice's length working there; and nothing in-tree reaches it on main.
Why it fails: LUCENE-6932 replaced the lazy model on purpose, two years after #6890, and listed it under API changes for all implementations. The warnings about wrap() concern double buffering and per-byte bounds-check cost, not seek semantics, and the fix adds nothing per byte (seekInternal runs only for seeks outside the buffer). #16619 is partly a fair point: it made the first fill of a DirectIOIndexInput slice lazy and pinned the old behaviour in two tests, one called "a characterization test, not an endorsement" (TestDirectIODirectory.java:380-386), the other (testSeekBeyondSliceLengthMatchesEagerBehaviour, :448-483) keeping a seek beyond a slice's length working, with the next read returning the byte past the slice's end while the target stays inside the buffered window. It is a different class with a different fix site, and the tests keep old behaviour through a refactor rather than define the contract, but they show maintainers keeping a similar quirk for compatibility. "Not reached in-tree" limits impact, not validity: wrap() is public static and BufferedIndexInput is a public, non-final base class whose default slice() returns it. This is a real contract violation; maintainers may still accept it as long-standing, low-value compatibility behaviour, which is a choice of priority, compatibility or fix site, not "works as designed".
Recommended A. EOF check in SlicedIndexInput.seekInternal
Give SlicedIndexInput.seekInternal the check NIOFSDirectory and RAFDirectory already have: throw EOFException when pos > length. With it the repro passes with assertions on and off, every core store test passes (29 classes, 777 tests), and so do core's static checks.
All options and trade-offs3 options · 741 words
Fix A is the recommended, validated fix. Fix B, the alternative site the adjudication named, was validated too. Option C is listed for completeness. All gradle runs were in scratch worktrees of 645d4fe14a20, removed afterwards.
Throw EOFException from SlicedIndexInput.seekInternal when pos > length, with the same check and message as NIOFSDirectory and RAFDirectory. seek(length) stays legal.
--- a/lucene/core/src/java/org/apache/lucene/store/BufferedIndexInput.java
+++ b/lucene/core/src/java/org/apache/lucene/store/BufferedIndexInput.java
@@ -420,7 +420,11 @@
}
@Override
- protected void seekInternal(long pos) {}
+ protected void seekInternal(long pos) throws EOFException {
+ if (pos > length) {
+ throw new EOFException("read past EOF: pos=" + pos + " vs length=" + length + ": " + this);
+ }
+ }
@Override
public void close() throws IOException {Trade-offs
- Covers
wrap(), the defaultslice(), clones and nested wraps (allSlicedIndexInput),skipBytes(throughseek) and positionalRandomAccessInputreads, which callseekInternaldirectly (BufferedIndexInput.java:239); far targets then getEOFExceptioninstead ofIllegalArgumentExceptiontoo (executed in a probe with this check shadowed: 0 accepted). - Cost: one comparison per seek outside the buffer, nothing per byte (the concern of #6890).
- Does not help third-party subclasses whose own
seekInternalis empty. - It throws from
seekInternal, whichBufferedIndexInput.seekcalls after it has setbufferStart = posand emptied the buffer (:330-332), so after the exceptiongetFilePointer()reports the rejected target.IndexInput#seekallows that ("then the stream is in an undetermined state"), but callers must not keep using the input after catching it; fix B rejects before any state change.
What the checks validated
- Gradle, scratch worktree of
645d4fe14a20, seedDEADBEEF, 2026-11-01: without the fix the repro fails with assertions on and off; with it, it passes with assertions on and off. - Every core store test,
./gradlew :lucene:core:test --tests 'org.apache.lucene.store.*'with the repro: 29 classes, 777 tests, 0 failures, 17 skipped (among themTestBufferedIndexInput,TestNIOFSDirectory,TestMMapDirectory,TestByteBuffersDirectory). ./gradlew :lucene:core:check -x testwith the fix and the repro as a new test file: passes (google-java-format, ecj lint, forbidden APIs; error-prone skipped). The full Lucene test suite was not run.- A first pass with the finder's two-line message passed every test but failed google-java-format, which wants the message on one line; the diff above is the formatted one.
- Earlier, with the fix shadowed into the stock jars: the finder's 3 tests, the independent reproducer's 6 tests and upstream
TestNIOFSDirectoryplusTestBufferedIndexInput(67 tests) pass; the minimal repro passes with assertions on and off; a 5.5.0 port passes with the same check.
Reject pos > length() in the out-of-buffer branch of BufferedIndexInput.seek, before any state changes.
--- a/lucene/core/src/java/org/apache/lucene/store/BufferedIndexInput.java
+++ b/lucene/core/src/java/org/apache/lucene/store/BufferedIndexInput.java
@@ -327,6 +327,10 @@
if (pos >= bufferStart && pos < (bufferStart + buffer.limit()))
buffer.position((int) (pos - bufferStart)); // seek within buffer
else {
+ if (pos > length()) {
+ throw new EOFException(
+ "seek past EOF: pos=" + pos + " vs length=" + length() + ": " + this);
+ }
bufferStart = pos;
buffer.limit(0); // trigger refill() on read
seekInternal(pos);Trade-offs
- Enforces the contract for every
BufferedIndexInputsubclass, including outside ones whoseseekInternalis a no-op (the in-tree test helperFaultyIndexInputinTestFieldsReaderhas one; it overridesslice(), so it is not this class). - A behaviour change for all subclasses, not just this class; one virtual
length()call per out-of-buffer seek; duplicates the checksNIOFSDirectoryandRAFDirectoryalready make. - Positional reads call
seekInternalwithoutseek(:239) and keep today's behaviour:EOFExceptionfromrefill(), butIllegalArgumentExceptionfor some targets more than 2^31 bytes past the end (code reading). A and B can be combined.
What the checks validated
- Gradle, same worktree setup, seed
DEADBEEF: the repro passes with assertions on and off. - Every core store test plus
TestFieldsReader: 30 classes, 779 tests, 0 failures, 17 skipped; every misc store test (TestRAFDirectory,TestDirectIODirectoryand three more): 5 classes, 244 tests, 0 failures, 6 skipped. ./gradlew :lucene:core:check -x testwith the fix and the repro: passes.
State in the wrap() and default slice() javadoc that a seek past the end is detected only on the next read.
Trade-offs
- No behaviour change, but it leaves
IndexInputs that break their own interface's contract, which LUCENE-6932 removed elsewhere; the regression test would pin the lenient behaviour instead.
Downstream: Apache Solr's block-cache CustomBufferedIndexInput.slice returns BufferedIndexInput.wrap(...), so its slices accept seeks past their end in the same way (source read, not run).
Semi-formal: a differential harness ran every 1- and 2-operation sequence (144,211 sequences on files of 0, 7 and 65 bytes) on wrap() inputs against an independent reference model and the stock Directory inputs, comparing values, file pointers and exception classes. Every seek or skip past the end was accepted; code reading then found the empty seekInternal.
The method in full150 words
- Method: bounded-exhaustive differential checking of the real jar against exact semantics (the hunt's semi-formal tier), in the hunt unit on store implementations. The harness ran every 1- and 2-operation sequence on
BufferedIndexInput.wrapover aByteBuffersDirectoryinput, on the top-level wrapper, its clones and its slices (144,211 sequences, 301,319 checks), against an independent reference model and the stock Directory inputs, comparing returned values, file pointers and exception classes. - Signal: every seek or
skipBytespast the end was accepted (bucket ACCEPTS_ILLEGAL) where the reference model and the stock inputs in the harness throwEOFException, with no value or file-pointer mismatch on legal operations. - Then: code reading located the empty
SlicedIndexInput.seekInternal, and the finding was reduced to a public-API JUnit test; later an independent reproducer, an intended-behaviour devil's advocate, cross-model reviews and an adjudication checked it, the history was bisected by execution, and the test was reduced to one method (2026-10-29).
No prior report found in GitHub issues, PRs and commits, the JIRA archive, the mailing lists, downstream projects or the web (searched 2026-10-26, re-checked 2026-11-01). Closest: LUCENE-6932 (#7990), which wrote the contract and fixed other inputs, and LUCENE-5828 (#6890), a 2014 Won't Fix about the wrapper's speed. Not fixed upstream as of 2026-10-30.
Searches4 searches
Closest items, none a report of this bug: #7990 (LUCENE-6932, 2016, wrote the seek contract, fixed four other inputs, never mentions this class); #6890 (LUCENE-5828, Won't Fix 2014, per-byte cost of the wrapper, predates the contract); #5437 (LUCENE-4371, the introducing change); #6743 (LUCENE-5681, the "may double buffer" warning); #14103 and #14106 (10.2.0, DirectIODirectory stopped using wrap()); #14126 (overflow-safe slice-creation bounds); #16619 (2026-09, pins an analogous DirectIOIndexInput slack as "a characterization test, not an endorsement"); the dev@ thread "this is a BUG?" (2015, about Solr's wrap() buffer size). The 2026-11-01 re-check also saw the open #10336 (LUCENE-9296, 2020), which asks for consistent exception types when reading past EOF across components; it is not about seek or this class. Gaps: GitHub search does not index inline review comments or PR diffs; list search matches words, not phrases; private channels are invisible.
| Where | When | How | Found |
|---|---|---|---|
| apache/lucene and apache/lucene-solr issues, PRs and commits | 2026-10-26 | two scripted prior-report searches (51 and 52 queries); 35 manual GitHub searches (SlicedIndexInput, BufferedIndexInput wrap, seekInternal, seek past EOF / beyond end, skipBytes, testSeekPastEOF, LUCENE-6932 and others); the changed files of all 409 open PRs (only #570 and #13981 touch BufferedIndexInput.java, neither changes SlicedIndexInput) | no report; #7990, #6890, #5437, #6743, #14103, #14126, #16619 are context |
| Mailing lists, JIRA archive, downstream, web | 2026-10-26 | dev@, java-user@, issues@ and commits@ queries; 7 manual and 8 scripted JIRA queries on LUCENE and SOLR; Solr, Elasticsearch, OpenSearch and Lucene.NET searches and a code search for downstream callers of wrap(); 8 web queries | no report; only Solr's CustomBufferedIndexInput.slice calls wrap() |
| Re-check | 2026-11-01 | GitHub issue and PR searches on apache/lucene, newest first: SlicedIndexInput (5 / 0 hits), BufferedIndexInput (30 / 11), seekInternal (3 issues), "seek past EOF" (0 PRs), seek beyond end EOFException (0 issues) | nothing new (#10336, open since 2020, is about exception types on read, not seek) |
| Upstream main and branches | 2026-11-01 | file contents at main 2f424fd428da (2026-10-30), branch_11x, branch_10_6 and the 10.6.0 tag | not fixed; BufferedIndexInput.java byte-identical, empty seekInternal at line 423 |
The empty seekInternal came with wrap() and the default slice() in LUCENE-4371 (4.9.0, 2014). It became a contract violation in 5.5.0, when LUCENE-6932 promised EOFException on seek and fixed the other inputs. Executed on 17 releases from 5.5.0 to 10.6.0 and on main: all affected.
Versions tested9 rows
Code introduced by LUCENE-4371, "Replace IndexInputSlicer with IndexInput.slice" (Robert Muir; trunk 7aeac4f07b35, 2014-05-17; branch_4x backport 3c8d05ca0629, shipped in 4.9.0; issue #5437, no PR). It added wrap() and the default slice() and moved the empty seekInternal over from Directory.SlicedIndexInput (4.0.0 to 4.8.1). Became a contract violation with LUCENE-6932 (ddc65d977f92, 2016-01-21, #7990), first released in 5.5.0: 4.9.0 to 5.4.1 behave the same but had no contract (a stock RAMDirectory input also accepted the seek there). The skipBytes variant starts in 9.0.0, when LUCENE-9480 made skipBytes call seek. In-tree reach: RAMDirectory slices in 4.9.x, misc NativeUnixDirectory and WindowsDirectory in 4.9 to 8.x (code reading), DirectIODirectory slices in 9.0 to 10.1.x (executed), none on main since #14103 (10.2.0). BufferedIndexInput.java is byte-identical at upstream main 2f424fd428da, branch_11x, branch_10_6 and the 10.6.0 tag.
| Version or commit | Verdict | Result |
|---|---|---|
main 645d4fe14a20 | affected | minimal repro fails with assertions on and off (standalone and gradle); passes with fix A and with fix B |
| 10.6.0, 10.5.2, 9.12.3 | affected | minimal repro fails (10.6.0 and 9.12.3 with assertions on, 10.5.2 with assertions off) |
| 9.0.0, 9.1.0, 10.0.0, 10.1.0, 10.2.0 | affected | the hunt's port or JUnit test accepts seek and skipBytes past the end; 9.1.0 to 10.1.0 also through DirectIODirectory slices |
| 5.5.0 to 8.11.4 (5.5.0, 5.5.5, 6.0.0, 6.6.6, 7.7.3, 8.0.0, 8.5.2, 8.6.0, 8.11.4) | first affected 5.5.0 | wrap and default-slice seeks accepted, stock controls throw; skipBytes still read the bytes and threw |
| 4.9.0 to 5.4.1 | no contract yet | same lenient seek, but the stock controls accepted it too: the contract did not exist |
| 4.8.1 | before the API | wrap() and IndexInput.slice do not exist (compile error) |
7aeac4f07b35 / 3c8d05ca0629 (LUCENE-4371) | introducing commit | added wrap(), the default slice() and the empty seekInternal (git blame at 645d4fe14a20) |
ddc65d977f92 (LUCENE-6932) | contract commit | wrote the seek EOFException sentence; did not touch SlicedIndexInput |
upstream main 2f424fd428da, branch_11x, branch_10_6 | read | byte-identical BufferedIndexInput.java (source read, not executed) |
Confirmed by an independent reproducer, an intended-behaviour devil's advocate, a reviewer from another model family (OpenAI GPT-5.6) and an adjudication (high confidence). A second cross-model review said not real only because it could not run code. This doc's gradle runs validated the fix.
Checks and verdicts351 words
Eight checks; none disputed the root cause.
| Check | Verdict | What it found |
|---|---|---|
| finder (differential harness) | candidate | Every past-end seek and skip accepted on the wrapper, its clones and slices; its 3-test JUnit fails 3 of 3 on main with assertions on and off, on 10.5.2 and 9.12.3; its DirectIODirectory repro fails on 10.1.0 and 9.12.3; the shadowed fix passes them and 67 upstream tests. |
| independent reproducer | real | Its own 6-test JUnit fails 4 of 6 on main with assertions on and off, on 10.5.2 and 9.12.3 (wrap seek(51) on 50 bytes: "no exception; getFilePointer()=51 length()=50"); both controls pass; a shadow with only the seekInternal check passes 6 of 6. |
| cross-model review, intended lens (OpenAI GPT-5.6, reading only) | real, medium confidence | Rated the intended-behaviour case moderate; separate runs of the repro and of the 10.1.0 DirectIO path failed, and the fix check passed. |
| intended-behaviour devil's advocate | real, low impact | Built and rejected the lazy-EOF, discouraged-wrap, #16619-precedent and unreachable cases; re-ran the repro on main with assertions on and off (fails). |
| cross-model review, refute lens (OpenAI GPT-5.6, reading only) | not real, low confidence (procedural) | It could not run code and voted not real on procedure only; its notes support a real contract violation and rate the intended-behaviour case weak. A re-run of the repro failed (3 of 3). |
| adjudication | confirmed, high confidence, minor | Re-ran the independent reproduction's test (4 of 6 fail) and the finder's (3 of 3 fail); chose impact class minor so as not to overclaim. |
| registry re-runs and credibility audit | confirmed | Fresh-seed re-runs fail on main and 10.5.2 and pass with the fix shadowed; a fourth audit re-ran the finder's test (fails 3 of 3). |
| brief validation (gradle) | fix A validated | The repro fails on stock main with assertions on and off and passes with fix A and with fix B; every core store test passes with each fix (plus TestFieldsReader and the misc store tests with B), and core's static checks pass with each; the repro also fails on the 10.6.0, 10.5.2 and 9.12.3 jars. |
The diagram as a still image, generated with AI and checked against the code. It is under 10 MB, the limit for a GitHub attachment.
- PNG 412 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,489 words
- Registry title
- Inputs from BufferedIndexInput.wrap and the default BufferedIndexInput.slice accept seek and skipBytes past the end instead of throwing EOFException
- Area
- store / BufferedIndexInput.SlicedIndexInput (BufferedIndexInput.wrap and the default slice())
- Priority
- Low, rank 1157: A late EOFException on a seek past the end, with no in-tree producer on main and no wrong bytes ever returned; it is rare rather than contrived.
- Impact class
- minor
- Impact note
- Only code that seeks or skips past the end of an input made by the public static BufferedIndexInput.wrap (Apache Solr's blockcache CustomBufferedIndexInput, an IndexInput, calls it in its own slice(); read only), by the default slice() of a BufferedIndexInput subclass that does not override it (third-party subclasses), or, in releases 9.0 to 10.1.x, by an opt-in DirectIODirectory slice is affected, with no scale needed; on main no in-tree Directory produces such an input, the illegal seek succeeds and getFilePointer() then reports a position beyond length(), and the first read after it still fails (EOFException, or an unchecked IllegalArgumentException for some targets more than 2^31 bytes past the end), so the effect is a late error and no byte from outside the input was ever returned (executed).
- Summary
- You take 20 bytes of a file with BufferedIndexInput.wrap("w", in, 10, 20), or a slice of a BufferedIndexInput subclass that keeps the default slice(), call seek(21) or skipBytes(21), and expect the EOFException that IndexInput#seek promises for a position beyond the end and that the stock Directories' inputs and slices throw in normal use. Instead the call succeeds and getFilePointer() returns 21; only the next read throws EOFException. The cause is the empty SlicedIndexInput.seekInternal: BufferedIndexInput.seek hands every target outside its buffer to seekInternal, and only readInternal checks the slice's length.
- Introduced
- 7aeac4f07b35d663009f946e07fb7bd3c90757a3 (trunk, svn r1595480) and its branch_4x backport 3c8d05ca062944b1a95b25a517a37d29f5ee21e9 (svn r1599274), which 4.9.0 shipped: LUCENE-4371 'Replace IndexInputSlicer with IndexInput.slice' (rmuir; JIRA and svn era, no PR; the issue is now apache/lucene#5437). It added the public static BufferedIndexInput.wrap and the default BufferedIndexInput.slice(), both returning the private SlicedIndexInput, whose empty 'protected void seekInternal(long pos) {}' it moved from Directory.SlicedIndexInput [read: git blame on main attributes the empty seekInternal and the slice() override to it; git log -S finds no other commit; exec: 4.8.1 cannot compile the probe because wrap() and IndexInput.slice do not exist, 4.9.0 accepts the seek]. The behaviour became a contract violation with ddc65d977f920013c5fca16c8ac75ae2c6895f9d (svn r1726039; branch_5x aa1a0cf3be8a), LUCENE-6932 (apache/lucene#7990), which wrote the IndexInput#seek EOFException sentence and fixed the RAM, NIOFS, SimpleFS and RAF inputs but not SlicedIndexInput [exec: the wrap and default-slice results are identical on 5.4.1 and 5.5.0; only the stock-directory controls change, from accepting the seek to throwing]. The deferred check itself is older: Directory.SlicedIndexInput (LUCENE-3218, 28f59a2e056, 4.0.0-4.8.1, executed on 4.8.1) and CompoundFileReader.CSIndexInput (1.4-3.0.0 tags) [read] (2014-05-17 (LUCENE-4371 on trunk; branch_4x backport 2014-06-02); 2016-01-21 (LUCENE-6932 wrote the contract it violates))
- First affected version
- 5.5.0 [exec, -ea and -da] (tagged 2016-02-20), the first release whose IndexInput#seek promises EOFException; 4.9.0 to 5.4.1 show the same lenient seek [exec] but had no such contract (a stock RAMDirectory input accepted a seek past the end there too); 4.8.1 and older lack wrap() and IndexInput.slice [exec: compile error]
- Affected versions
- every release from 5.5.0 to 10.6.0 and main ea57360912ed; never fixed. [exec] portable main() ports and the JUnit repro accept the illegal seek of wrap() inputs and default slices on 5.5.0 (-ea and -da), 5.5.5, 6.0.0, 6.6.6, 7.7.3, 8.0.0, 8.5.2, 8.6.0, 8.11.4, 9.0.0, 9.1.0, 9.12.3, 10.0.0, 10.1.0, 10.2.0, 10.5.2 (-ea and -da) and main (-ea and -da); the skipBytes variant exists only from 9.0.0, because LUCENE-9480 made IndexInput.skipBytes call seek (8.11.4 and older read the bytes and throw EOFException). In-tree reach through opt-in DirectIODirectory slices, which returned BufferedIndexInput.wrap: 9.0.0 to 10.1.x [exec on 9.1.0, 9.12.3, 10.0.0 and 10.1.0], gone from 10.2.0 (#14103) [read]. [read] the releases between the executed ones carry the same empty seekInternal (the class changed only in LUCENE-5681, LUCENE-9271, LUCENE-9570 and #14126, none touching seekInternal); upstream main f4cc6ac2097c and branch_10x 657014b3c579 still have it (read 2026-10-26); 10.6.0 (released 2026-10-29) fails the one-test minimal repro on its release jars (SHA-1 checked against Maven Central, -ea and -da, 2026-11-01) [exec]. All runs: JDK 25, SHA-1-verified release jars from Maven Central
- 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: exhaustive check; also reached by code reading
- Upstream and prior reports
- none found (2026-10-26): two scripted prior-report searches (51 and 52 queries over apache/lucene issues, PRs and commits, downstream repositories, the dev@, java-user@, issues@ and commits@ lists, the JIRA archive and the web), 35 manual GitHub searches (apache/lucene, apache/lucene-solr, apache/solr, Elasticsearch, OpenSearch, Lucene.NET), a code search for downstream callers of BufferedIndexInput.wrap (only Solr's CustomBufferedIndexInput.slice), the changed files of all 409 open apache/lucene PRs (BufferedIndexInput.java is touched only by #570 and #13981, neither changes SlicedIndexInput), list and JIRA queries, 8 web queries, and the full local git history. Related, not this bug: #7990 (LUCENE-6932, 2016, wrote the seek-past-EOF contract, chose seek-time EOF over deferring it to the next read, fixed four other inputs, never mentions SlicedIndexInput or wrap); #6890 (LUCENE-5828, Won't Fix 2014, a proposed delegating rewrite of SlicedIndexInput rejected over per-byte bounds-check cost, with uschindler's 'The slice wrapper is not used in any core directories, so maybe we should leave it as it is'; predates the contract, nothing about seek); #5437 (LUCENE-4371, the introducing change); #6743 (LUCENE-5681, the 'may double buffer' warning); #14103 (10.2.0, DirectIODirectory stopped using wrap() for slices, for double-buffering reasons); #14126 (overflow-safe slice-creation bounds check); #16619 (merged 2026-09-03, pins a similar lenient seek past a slice's length in DirectIOIndexInput as 'a characterization test, not an endorsement'; a different class and fix site); dev@ 'this is a BUG?' (2015, about Solr's wrap() buffer size). Not fixed upstream (main f4cc6ac2097c, branch_10x 657014b3c579, read 2026-10-26).
- Lucene commit tested
- main
645d4fe14a20: the minimal repro with assertions on and off, standalone and through gradle; fix A (final form) and fix B in scratch worktrees with the repro, every core store test,TestFieldsReaderand the misc store tests (fix B) and core's static checks. SHA-1-checked release jars: the minimal repro on 10.6.0, 10.5.2 and 9.12.3; the hunt's JUnit tests andmain()ports on 16 releases from 5.5.0 to 10.5.2, plus 4.8.1 to 5.4.1 for the boundary. The hunt ran on mainea57360912ed, whereBufferedIndexInput.javais byte-identical.
- Finding record
lucene/core/src/java/org/apache/lucene/store/BufferedIndexInput.java:326-334 (at ea57360912ed) (seek: a target outside the buffer sets bufferStart = pos and calls seekInternal(pos)), :336-342 (seekInternal javadoc: 'Sets current position in this file, where the next readInternal will occur'), :354-357 (default slice() returns wrap(...)), :371-379 (public static wrap, javadoc 'in most cases ineffective, because it may double buffer!'), :382-400 (SlicedIndexInput, constructor bounds check), :411-420 (readInternal checks start + len > length), :422-423 ('protected void seekInternal(long pos) {}')lucene/core/src/java/org/apache/lucene/store/IndexInput.java:68-75 (at ea57360912ed) (seek: 'If this is beyond the end of the file then this will throw EOFException and then the stream is in an undetermined state'), :77-91 (skipBytes: 'functionally equivalent to seeking to getFilePointer() + numBytes', implemented as seek(skipTo))lucene/core/src/java/org/apache/lucene/store/NIOFSDirectory.java:136 (at ea57360912ed) (own slice()), :203-208 (seekInternal throws EOFException for pos > length()); lucene/misc/src/java/org/apache/lucene/misc/store/RAFDirectory.java:138, :191 (own slice(), checked seekInternal); lucene/misc/src/java/org/apache/lucene/misc/store/DirectIODirectory.java:604 (own slice() since #14103); no other in-tree reference to BufferedIndexInput.wrap or SlicedIndexInput (grep)lucene/test-framework/src/java/org/apache/lucene/tests/store/BaseDirectoryTestCase.java:722-747 (at ea57360912ed) (testSeekPastEOF, 'Seeking past EOF should always throw EOFException'), :1353-1370 (testSeekBeyondEndOfFile); lucene/CHANGES.txt:9693-9694 (LUCENE-6932, 'IndexInput.seek implementations now throw EOFException if you seek beyond the end of the file')
- The finding in 30 seconds
lucene/core/src/java/org/apache/lucene/store/BufferedIndexInput.java:25, :290-295, :303, :321-322, :326-334, :336-342, :354-357, :371-379, :382, :411-420, :422-423 (at 645d4fe14a20)lucene/core/src/java/org/apache/lucene/store/IndexInput.java:69-75, :80, :86-91 (at 645d4fe14a20)lucene/core/src/java/org/apache/lucene/store/NIOFSDirectory.java:136, :203-208; lucene/misc/src/java/org/apache/lucene/misc/store/RAFDirectory.java:138, :191-196 (at 645d4fe14a20)
- Who is affected
lucene/core/src/java/org/apache/lucene/store/NIOFSDirectory.java:136; lucene/misc/src/java/org/apache/lucene/misc/store/RAFDirectory.java:138; lucene/misc/src/java/org/apache/lucene/misc/store/DirectIODirectory.java:604 (own slice() implementations, at 645d4fe14a20)grep for 'BufferedIndexInput.wrap', 'SlicedIndexInput' and 'extends BufferedIndexInput' over lucene/ at 645d4fe14a20 (no in-tree caller of wrap(); every subclass overrides slice())lucene/core/src/java/org/apache/lucene/codecs/CodecUtil.java:471-518 (checkFooter(in, priorException); remaining < footerLength() at :482 throws CorruptIndexException) (at 645d4fe14a20)
- Why it looks unintended
lucene/core/src/java/org/apache/lucene/store/IndexInput.java:69-71, :80 (at 645d4fe14a20)lucene/CHANGES.txt:9726-9727 (LUCENE-6932, 5.5.0 API Changes), :11953-11958 (LUCENE-5681) (at 645d4fe14a20)lucene/test-framework/src/java/org/apache/lucene/tests/store/BaseDirectoryTestCase.java:722-747 (comment :731), :1353-1370 (at 645d4fe14a20)lucene/misc/src/test/org/apache/lucene/misc/store/TestDirectIODirectory.java:380-386 (testFirstFillEofSemanticsMatchEagerFill javadoc), :448-483 (testSeekBeyondSliceLengthMatchesEagerBehaviour), both added by 1f5d30938bf (#16619, 2026-09-03) (at 645d4fe14a20)
- Suggested fix
lucene/core/src/java/org/apache/lucene/store/BufferedIndexInput.java:239, :326-334; lucene/core/src/test/org/apache/lucene/index/TestFieldsReader.java:142-198 (at 645d4fe14a20)
- Prior reports
a Lucene clone: git rev-parse <ref>:lucene/core/src/java/org/apache/lucene/store/BufferedIndexInput.java for 645d4fe14a20, origin/main 2f424fd428da, origin/branch_11x, origin/branch_10_6, releases/lucene/10.6.0 (blob 6ac73d78d29f)
- History
a Lucene clone: git rev-parse <ref>:lucene/core/src/java/org/apache/lucene/store/BufferedIndexInput.java for 645d4fe14a20, origin/main 2f424fd428da, origin/branch_11x, origin/branch_10_6, releases/lucene/10.6.0
- TestBufferedIndexInputWrapSeekPastEOF.java (JUnit, one test):
repro/LFV-480/src/org/apache/lucene/store/TestBufferedIndexInputWrapSeekPastEOF.java(37 lines)
Made in parallel with this doc from the mechanism in the brief; the PNG is the summary frame.
assets/LFV-480/bug.png(412 KB)
