Skip to content

Commit 471b03c

Browse files
authored
Merge main into develop (#5526)
2 parents 40f3643 + 154f563 commit 471b03c

25 files changed

+3294
-135
lines changed

.github/workflows/copilot-setup-steps.yml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,17 @@ jobs:
2222
distribution: 'temurin'
2323
cache: maven
2424

25+
- name: Prepare custom Maven repository directory
26+
run: mkdir -p .m2_repo
27+
28+
- name: Cache custom Maven repository
29+
uses: actions/cache@v4
30+
with:
31+
path: .m2_repo
32+
key: ${{ runner.os }}-m2-repo-${{ hashFiles('**/pom.xml') }}
33+
restore-keys: |
34+
${{ runner.os }}-m2-repo-
35+
2536
- name: Validate formatting configuration
2637
run: mvn -B -T 2C -Dmaven.repo.local=.m2_repo formatter:validate impsort:check xml-format:xml-check
2738

core/common/io/src/main/java/org/eclipse/rdf4j/common/io/NioFile.java

Lines changed: 64 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
package org.eclipse.rdf4j.common.io;
1212

1313
import java.io.Closeable;
14+
import java.io.EOFException;
1415
import java.io.File;
1516
import java.io.IOException;
1617
import java.nio.ByteBuffer;
@@ -22,6 +23,7 @@
2223
import java.nio.file.StandardOpenOption;
2324
import java.util.EnumSet;
2425
import java.util.Set;
26+
import java.util.concurrent.TimeUnit;
2527

2628
/**
2729
* File wrapper that protects against concurrent file closing events due to e.g. {@link Thread#interrupt() thread
@@ -45,6 +47,13 @@ public final class NioFile implements Closeable {
4547

4648
private volatile FileChannel fc;
4749

50+
/**
51+
* Disable strict guards via system property to maintain legacy behavior without additional exceptions. Property:
52+
* org.eclipse.rdf4j.common.io.niofile.disableStrictGuards (default: false)
53+
*/
54+
private static final boolean STRICT_GUARDS = !Boolean
55+
.getBoolean("org.eclipse.rdf4j.common.io.niofile.disableStrictGuards");
56+
4857
private volatile boolean explictlyClosed;
4958

5059
/**
@@ -238,12 +247,24 @@ public long transferTo(long position, long count, WritableByteChannel target) th
238247
* @throws IOException
239248
*/
240249
public int write(ByteBuffer buf, long offset) throws IOException {
250+
final int startPosition = buf.position();
241251
while (true) {
242252
try {
243-
return fc.write(buf, offset);
253+
// Ensure the entire buffer is written, even if the underlying channel performs a partial write
254+
while (buf.hasRemaining()) {
255+
long position = offset + (buf.position() - startPosition);
256+
int n = fc.write(buf, position);
257+
if (n == 0) {
258+
// Avoid tight spin in pathological cases: reattempt write
259+
// FileChannel positional writes may occasionally return 0 without progress
260+
continue;
261+
}
262+
}
263+
return buf.position() - startPosition;
244264
} catch (ClosedByInterruptException e) {
245265
throw e;
246266
} catch (ClosedChannelException e) {
267+
// Preserve already-consumed bytes on retry
247268
reopen(e);
248269
}
249270
}
@@ -258,12 +279,29 @@ public int write(ByteBuffer buf, long offset) throws IOException {
258279
* @throws IOException
259280
*/
260281
public int read(ByteBuffer buf, long offset) throws IOException {
282+
final int startPosition = buf.position();
261283
while (true) {
262284
try {
263-
return fc.read(buf, offset);
285+
while (buf.hasRemaining()) {
286+
long position = offset + (buf.position() - startPosition);
287+
int n = fc.read(buf, position);
288+
if (n < 0) {
289+
// Preserve FileChannel contract: if no bytes were read and EOF is reached, return -1
290+
if (buf.position() == startPosition) {
291+
return -1;
292+
}
293+
break; // EOF after having read some bytes
294+
}
295+
if (n == 0) {
296+
// Avoid tight spin; allow retry in case of transient 0-byte read
297+
continue;
298+
}
299+
}
300+
return buf.position() - startPosition;
264301
} catch (ClosedByInterruptException e) {
265302
throw e;
266303
} catch (ClosedChannelException e) {
304+
// Preserve already-consumed bytes on retry
267305
reopen(e);
268306
}
269307
}
@@ -277,7 +315,10 @@ public int read(ByteBuffer buf, long offset) throws IOException {
277315
* @throws IOException
278316
*/
279317
public void writeBytes(byte[] value, long offset) throws IOException {
280-
write(ByteBuffer.wrap(value), offset);
318+
int write = write(ByteBuffer.wrap(value), offset);
319+
if (STRICT_GUARDS && write != value.length) {
320+
throw new IOException("Incomplete writeBytes: expected " + value.length + ", wrote " + write);
321+
}
281322
}
282323

283324
/**
@@ -290,7 +331,10 @@ public void writeBytes(byte[] value, long offset) throws IOException {
290331
*/
291332
public byte[] readBytes(long offset, int length) throws IOException {
292333
ByteBuffer buf = ByteBuffer.allocate(length);
293-
read(buf, offset);
334+
int read = read(buf, offset);
335+
if (STRICT_GUARDS && read < length) {
336+
throw new EOFException("Unexpected EOF in readBytes: expected " + length + ", read " + read);
337+
}
294338
return buf.array();
295339
}
296340

@@ -326,7 +370,10 @@ public byte readByte(long offset) throws IOException {
326370
public void writeLong(long value, long offset) throws IOException {
327371
ByteBuffer buf = ByteBuffer.allocate(8);
328372
buf.putLong(0, value);
329-
write(buf, offset);
373+
int write = write(buf, offset);
374+
if (STRICT_GUARDS && write != 8) {
375+
throw new IOException("Incomplete writeLong: wrote " + write);
376+
}
330377
}
331378

332379
/**
@@ -338,7 +385,10 @@ public void writeLong(long value, long offset) throws IOException {
338385
*/
339386
public long readLong(long offset) throws IOException {
340387
ByteBuffer buf = ByteBuffer.allocate(8);
341-
read(buf, offset);
388+
int read = read(buf, offset);
389+
if (STRICT_GUARDS && read < 8) {
390+
throw new EOFException("Unexpected EOF in readLong: read " + read);
391+
}
342392
return buf.getLong(0);
343393
}
344394

@@ -352,7 +402,10 @@ public long readLong(long offset) throws IOException {
352402
public void writeInt(int value, long offset) throws IOException {
353403
ByteBuffer buf = ByteBuffer.allocate(4);
354404
buf.putInt(0, value);
355-
write(buf, offset);
405+
int write = write(buf, offset);
406+
if (STRICT_GUARDS && write != 4) {
407+
throw new IOException("Incomplete writeInt: wrote " + write);
408+
}
356409
}
357410

358411
/**
@@ -364,7 +417,10 @@ public void writeInt(int value, long offset) throws IOException {
364417
*/
365418
public int readInt(long offset) throws IOException {
366419
ByteBuffer buf = ByteBuffer.allocate(4);
367-
read(buf, offset);
420+
int read = read(buf, offset);
421+
if (STRICT_GUARDS && read < 4) {
422+
throw new EOFException("Unexpected EOF in readInt: read " + read);
423+
}
368424
return buf.getInt(0);
369425
}
370426
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
/*******************************************************************************
2+
* Copyright (c) 2025 Eclipse RDF4J contributors.
3+
*
4+
* All rights reserved. This program and the accompanying materials
5+
* are made available under the terms of the Eclipse Distribution License v1.0
6+
* which accompanies this distribution, and is available at
7+
* http://www.eclipse.org/org/documents/edl-v10.php.
8+
*
9+
* SPDX-License-Identifier: BSD-3-Clause
10+
*******************************************************************************/
11+
package org.eclipse.rdf4j.common.io;
12+
13+
import static org.junit.jupiter.api.Assertions.assertEquals;
14+
15+
import java.io.File;
16+
import java.nio.ByteBuffer;
17+
import java.nio.file.Files;
18+
import java.nio.file.Path;
19+
20+
import org.junit.jupiter.api.Test;
21+
import org.junit.jupiter.api.io.TempDir;
22+
23+
/**
24+
* Verifies the EOF contract for NioFile#read(ByteBuffer,long): when the underlying channel is at EOF and no bytes were
25+
* read, the method must return -1 (EOF sentinel), not 0.
26+
*/
27+
public class NioFileEOFContractTest {
28+
29+
@TempDir
30+
File tmp;
31+
32+
@Test
33+
public void readReturnsMinusOneAtEofWhenNoBytesRead() throws Exception {
34+
Path p = tmp.toPath().resolve("empty.dat");
35+
Files.write(p, new byte[0]); // empty file
36+
37+
try (NioFile nf = new NioFile(p.toFile())) {
38+
ByteBuffer buf = ByteBuffer.allocate(16);
39+
int n = nf.read(buf, 0);
40+
assertEquals(-1, n, "EOF sentinel -1 expected when no bytes were read");
41+
}
42+
}
43+
}
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
/*******************************************************************************
2+
* Copyright (c) 2025 Eclipse RDF4J contributors.
3+
*
4+
* All rights reserved. This program and the accompanying materials
5+
* are made available under the terms of the Eclipse Distribution License v1.0
6+
* which accompanies this distribution, and is available at
7+
* http://www.eclipse.org/org/documents/edl-v10.php.
8+
*
9+
* SPDX-License-Identifier: BSD-3-Clause
10+
*******************************************************************************/
11+
package org.eclipse.rdf4j.common.io;
12+
13+
import static org.junit.jupiter.api.Assertions.assertTrue;
14+
15+
import java.io.File;
16+
import java.nio.ByteBuffer;
17+
import java.nio.file.Files;
18+
import java.nio.file.Path;
19+
20+
import org.junit.jupiter.api.Assumptions;
21+
import org.junit.jupiter.api.Test;
22+
import org.junit.jupiter.api.io.TempDir;
23+
24+
/**
25+
* Regression test for heap guard misclassification: NioFile.read(ByteBuffer,long) must not preemptively throw
26+
* IOException based solely on buffer size when the buffer is already allocated. Previously, a guard attempted to
27+
* compare the requested read size to free heap and could throw before performing any IO, breaking legitimate large
28+
* heap-buffer reads.
29+
*/
30+
public class NioFileLargeReadGuardRegressionTest {
31+
32+
@TempDir
33+
File tmp;
34+
35+
@Test
36+
public void largeHeapBufferReadDoesNotPreemptivelyThrow() throws Exception {
37+
// Prepare a small file; the size/content are irrelevant to the regression
38+
Path p = tmp.toPath().resolve("small.dat");
39+
byte[] small = new byte[1024];
40+
Files.write(p, small);
41+
42+
// Allocate a heap buffer just above the previous guard threshold (128MB)
43+
final int size = 129 * 1024 * 1024; // 129MB
44+
final ByteBuffer buf;
45+
try {
46+
buf = ByteBuffer.allocate(size);
47+
} catch (OutOfMemoryError oom) {
48+
// Not enough heap available in this environment to run the check; skip rather than fail
49+
Assumptions.assumeTrue(false, "Insufficient heap to allocate 129MB test buffer");
50+
return; // unreachable, but keeps compiler happy
51+
}
52+
53+
// Reduce observed free heap below the requested read size so that a pre-check (if present) would misclassify
54+
// this legitimate large buffer read as risky and throw. We allocate temporary blocks to lower free heap.
55+
// If we cannot safely get below the threshold, skip the test to avoid flakiness across environments.
56+
if (!saturateHeapToBelow(size)) {
57+
Assumptions.assumeTrue(false, "Could not reduce free heap below threshold deterministically");
58+
return;
59+
}
60+
61+
try (NioFile nf = new NioFile(p.toFile())) {
62+
int n = nf.read(buf, 0);
63+
// Success is simply: no IOException thrown; value can be -1 (EOF) or >=0 bytes read
64+
assertTrue(n >= -1, "read() returned unexpected value");
65+
}
66+
}
67+
68+
private static boolean saturateHeapToBelow(long bytes) {
69+
final Runtime rt = Runtime.getRuntime();
70+
final java.util.List<byte[]> blocks = new java.util.ArrayList<>();
71+
final int block = 8 * 1024 * 1024; // 8MB steps to avoid big spikes
72+
try {
73+
for (int i = 0; i < 512; i++) { // cap allocations defensively
74+
long alloc = rt.totalMemory() - rt.freeMemory();
75+
long free = rt.maxMemory() - alloc;
76+
if (free <= bytes) {
77+
return true;
78+
}
79+
int size = (int) Math.min(block, Math.max(1, free - bytes));
80+
blocks.add(new byte[size]);
81+
}
82+
} catch (OutOfMemoryError oom) {
83+
// Best-effort: after OOM, the VM may still have reduced free; treat as inconclusive
84+
}
85+
long alloc = rt.totalMemory() - rt.freeMemory();
86+
long free = rt.maxMemory() - alloc;
87+
return free <= bytes;
88+
}
89+
}

0 commit comments

Comments
 (0)