NioFileLargeReadGuardRegressionTest.java

/*******************************************************************************
 * Copyright (c) 2025 Eclipse RDF4J contributors.
 *
 * All rights reserved. This program and the accompanying materials
 * are made available under the terms of the Eclipse Distribution License v1.0
 * which accompanies this distribution, and is available at
 * http://www.eclipse.org/org/documents/edl-v10.php.
 *
 * SPDX-License-Identifier: BSD-3-Clause
 *******************************************************************************/
package org.eclipse.rdf4j.common.io;

import static org.junit.jupiter.api.Assertions.assertTrue;

import java.io.File;
import java.nio.ByteBuffer;
import java.nio.file.Files;
import java.nio.file.Path;

import org.junit.jupiter.api.Assumptions;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;

/**
 * Regression test for heap guard misclassification: NioFile.read(ByteBuffer,long) must not preemptively throw
 * IOException based solely on buffer size when the buffer is already allocated. Previously, a guard attempted to
 * compare the requested read size to free heap and could throw before performing any IO, breaking legitimate large
 * heap-buffer reads.
 */
public class NioFileLargeReadGuardRegressionTest {

	@TempDir
	File tmp;

	@Test
	public void largeHeapBufferReadDoesNotPreemptivelyThrow() throws Exception {
		// Prepare a small file; the size/content are irrelevant to the regression
		Path p = tmp.toPath().resolve("small.dat");
		byte[] small = new byte[1024];
		Files.write(p, small);

		// Allocate a heap buffer just above the previous guard threshold (128MB)
		final int size = 129 * 1024 * 1024; // 129MB
		final ByteBuffer buf;
		try {
			buf = ByteBuffer.allocate(size);
		} catch (OutOfMemoryError oom) {
			// Not enough heap available in this environment to run the check; skip rather than fail
			Assumptions.assumeTrue(false, "Insufficient heap to allocate 129MB test buffer");
			return; // unreachable, but keeps compiler happy
		}

		// Reduce observed free heap below the requested read size so that a pre-check (if present) would misclassify
		// this legitimate large buffer read as risky and throw. We allocate temporary blocks to lower free heap.
		// If we cannot safely get below the threshold, skip the test to avoid flakiness across environments.
		if (!saturateHeapToBelow(size)) {
			Assumptions.assumeTrue(false, "Could not reduce free heap below threshold deterministically");
			return;
		}

		try (NioFile nf = new NioFile(p.toFile())) {
			int n = nf.read(buf, 0);
			// Success is simply: no IOException thrown; value can be -1 (EOF) or >=0 bytes read
			assertTrue(n >= -1, "read() returned unexpected value");
		}
	}

	private static boolean saturateHeapToBelow(long bytes) {
		final Runtime rt = Runtime.getRuntime();
		final java.util.List<byte[]> blocks = new java.util.ArrayList<>();
		final int block = 8 * 1024 * 1024; // 8MB steps to avoid big spikes
		try {
			for (int i = 0; i < 512; i++) { // cap allocations defensively
				long alloc = rt.totalMemory() - rt.freeMemory();
				long free = rt.maxMemory() - alloc;
				if (free <= bytes) {
					return true;
				}
				int size = (int) Math.min(block, Math.max(1, free - bytes));
				blocks.add(new byte[size]);
			}
		} catch (OutOfMemoryError oom) {
			// Best-effort: after OOM, the VM may still have reduced free; treat as inconclusive
		}
		long alloc = rt.totalMemory() - rt.freeMemory();
		long free = rt.maxMemory() - alloc;
		return free <= bytes;
	}
}