diff --git a/core/src/main/c/share/files.c b/core/src/main/c/share/files.c index 02fb93a1..75f228fa 100644 --- a/core/src/main/c/share/files.c +++ b/core/src/main/c/share/files.c @@ -73,22 +73,9 @@ JNIEXPORT jint JNICALL Java_io_questdb_client_std_Files_openAppend0 } JNIEXPORT jint JNICALL Java_io_questdb_client_std_Files_openCleanRW0 - (JNIEnv *e, jclass cl, jlong lpszName, jlong size) { + (JNIEnv *e, jclass cl, jlong lpszName) { int fd; RESTARTABLE(open((const char *) (uintptr_t) lpszName, O_CREAT | O_TRUNC | O_RDWR, 0644), fd); - if (fd < 0) { - return -1; - } - if (size > 0) { - int rc; - RESTARTABLE(ftruncate(fd, (off_t) size), rc); - if (rc != 0) { - int saved = errno; - close(fd); - errno = saved; - return -1; - } - } return (jint) fd; } @@ -146,8 +133,44 @@ JNIEXPORT jboolean JNICALL Java_io_questdb_client_std_Files_truncate JNIEXPORT jboolean JNICALL Java_io_questdb_client_std_Files_allocate (JNIEnv *e, jclass cl, jint fd, jlong size) { + /* Cross-platform contract — full version lives on + * Files.allocate's javadoc; key invariants restated here so the + * implementation reads on its own: + * - Never shrinks: target = max(size, currentSize); if size <= + * currentSize, return success without touching the file. + * - Reserves real disk blocks for [currentSize, target). The + * pre-existing range [0, currentSize) is left untouched so the + * three platforms agree on what the call does — anchoring the + * reservation at currentSize matches macOS's F_PEOFPOSMODE + * semantics (which can only allocate beyond EOF without writes + * that would corrupt mmap'd content). + * - Real errors (ENOSPC, EFBIG, EIO, ...) surface as JNI_FALSE. + * Filesystem-doesn't-support errnos degrade to a sparse + * ftruncate fallback per sf-client.md §6. */ + struct stat st; + if (fstat((int) fd, &st) != 0) { + return JNI_FALSE; + } + off_t target = (off_t) size; + if (st.st_size > target) { + target = st.st_size; + } + if (target == st.st_size) { + /* Nothing to extend, nothing to reserve. Returning here is what + * makes the never-shrinks property hold across the + * ftruncate-fallback path below. */ + return JNI_TRUE; + } + off_t newBytes = target - st.st_size; + #if defined(__linux__) - int res = posix_fallocate((int) fd, 0, (off_t) size); + /* posix_fallocate at offset=currentSize reserves only the + * newly-extended range [currentSize, target), matching macOS's + * F_PEOFPOSMODE behaviour and keeping the cross-platform contract + * consistent on whether pre-existing sparse holes get filled (they + * do not). On success the file's logical size is already target — + * we return early to skip the unnecessary ftruncate. */ + int res = posix_fallocate((int) fd, st.st_size, newBytes); if (res == 0) { return JNI_TRUE; } @@ -155,13 +178,20 @@ JNIEXPORT jboolean JNICALL Java_io_questdb_client_std_Files_allocate errno = res; return JNI_FALSE; } - /* fall through to ftruncate */ + /* Filesystem doesn't support fallocate; fall through to ftruncate. + * That is the sparse-fallback path — extends to target but blocks + * remain sparse, so a later store past an unallocated page may + * still raise SIGBUS. Per the contract, ftruncate here only ever + * grows (target > st.st_size) so "never shrinks" still holds. */ #elif defined(__APPLE__) fstore_t fst; fst.fst_flags = F_ALLOCATECONTIG | F_ALLOCATEALL; fst.fst_posmode = F_PEOFPOSMODE; fst.fst_offset = 0; - fst.fst_length = (off_t) size; + /* fst_length is the number of bytes to allocate BEYOND EOF — not the + * target total. Passing the full target would over-allocate by + * currentSize on a non-empty file. */ + fst.fst_length = newBytes; fst.fst_bytesalloc = 0; if (fcntl((int) fd, F_PREALLOCATE, &fst) == -1) { /* Contiguous allocation failed (e.g. fragmented filesystem); retry @@ -175,9 +205,11 @@ JNIEXPORT jboolean JNICALL Java_io_questdb_client_std_Files_allocate return JNI_FALSE; } } + /* F_PREALLOCATE never advances EOF, so ftruncate below is part of + * the normal path on macOS — it's NOT just a sparse-fallback. */ #endif int res2; - RESTARTABLE(ftruncate((int) fd, (off_t) size), res2); + RESTARTABLE(ftruncate((int) fd, target), res2); return res2 == 0 ? JNI_TRUE : JNI_FALSE; } diff --git a/core/src/main/c/windows/files.c b/core/src/main/c/windows/files.c index 042aa9c9..119f6315 100644 --- a/core/src/main/c/windows/files.c +++ b/core/src/main/c/windows/files.c @@ -124,25 +124,12 @@ JNIEXPORT jint JNICALL Java_io_questdb_client_std_Files_openAppend0 } JNIEXPORT jint JNICALL Java_io_questdb_client_std_Files_openCleanRW0 - (JNIEnv *e, jclass cl, jlong lpszName, jlong size) { - jint fd = open_file((const char *) (uintptr_t) lpszName, - GENERIC_READ | GENERIC_WRITE, - FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, - CREATE_ALWAYS, - FILE_ATTRIBUTE_NORMAL); - if (fd < 0) { - return fd; - } - if (size > 0) { - FILE_END_OF_FILE_INFO eof; - eof.EndOfFile.QuadPart = size; - if (!SetFileInformationByHandle(FD_TO_HANDLE(fd), FileEndOfFileInfo, &eof, sizeof(eof))) { - SaveLastError(); - CloseHandle(FD_TO_HANDLE(fd)); - return -1; - } - } - return fd; + (JNIEnv *e, jclass cl, jlong lpszName) { + return open_file((const char *) (uintptr_t) lpszName, + GENERIC_READ | GENERIC_WRITE, + FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, + CREATE_ALWAYS, + FILE_ATTRIBUTE_NORMAL); } /* ReadFile/WriteFile take a DWORD (uint32) byte count, but the JNI signature @@ -241,18 +228,20 @@ JNIEXPORT jboolean JNICALL Java_io_questdb_client_std_Files_truncate JNIEXPORT jboolean JNICALL Java_io_questdb_client_std_Files_allocate (JNIEnv *e, jclass cl, jint fd, jlong size) { - /* SetEndOfFile alone leaves the file sparse on NTFS: clusters are - * allocated lazily as writes occur. If the disk fills up between - * create and write, the cache manager raises an in-page exception - * on the writing thread when it flushes a mapped page — a - * SIGBUS-class failure that tears down the JVM. FILE_ALLOCATION_INFO - * instructs NTFS to physically reserve clusters now and returns - * ERROR_DISK_FULL synchronously on the call site, matching the - * posix_fallocate contract. - * - * Match the POSIX behaviour of posix_fallocate(fd, 0, size): round - * the request up to the existing logical size so an allocate call - * never shrinks a file that the caller already extended. */ + /* Cross-platform contract — full version lives on + * Files.allocate's javadoc; key invariants restated here so the + * implementation reads on its own: + * - Never shrinks: target = max(size, currentSize); if size <= + * currentSize, return success without touching the file. + * - Reserves real disk clusters for [currentSize, target). On NTFS + * FILE_ALLOCATION_INFO is file-scope (no per-range API), so it + * implicitly re-reserves [0, currentSize) as well — visible only + * to a caller who deliberately created sparse holes inside that + * range, and that caller should treat hole-filling as + * non-portable behaviour. + * - ERROR_DISK_FULL surfaces as JNI_FALSE. There is no + * sparse-fallback equivalent — Windows always reserves or + * fails; spec-compliant fallback only applies on Linux/macOS. */ HANDLE handle = FD_TO_HANDLE(fd); LARGE_INTEGER current; @@ -261,6 +250,12 @@ JNIEXPORT jboolean JNICALL Java_io_questdb_client_std_Files_allocate return JNI_FALSE; } jlong target = size > current.QuadPart ? size : (jlong) current.QuadPart; + if (target == current.QuadPart) { + /* Nothing to extend, nothing to reserve. The early-return is + * what makes "never shrinks" hold and keeps behaviour aligned + * with the Linux/macOS short-circuit. */ + return JNI_TRUE; + } FILE_ALLOCATION_INFO alloc; alloc.AllocationSize.QuadPart = target; @@ -270,14 +265,13 @@ JNIEXPORT jboolean JNICALL Java_io_questdb_client_std_Files_allocate } /* FILE_ALLOCATION_INFO reserves clusters but does not advance EOF. - * Extend the logical size separately when growing the file. */ - if (size > current.QuadPart) { - FILE_END_OF_FILE_INFO eof; - eof.EndOfFile.QuadPart = size; - if (!SetFileInformationByHandle(handle, FileEndOfFileInfo, &eof, sizeof(eof))) { - SaveLastError(); - return JNI_FALSE; - } + * We've already ruled out target == current above, so the file + * always needs its logical size pushed out to target. */ + FILE_END_OF_FILE_INFO eof; + eof.EndOfFile.QuadPart = target; + if (!SetFileInformationByHandle(handle, FileEndOfFileInfo, &eof, sizeof(eof))) { + SaveLastError(); + return JNI_FALSE; } return JNI_TRUE; } diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/AckWatermark.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/AckWatermark.java index baea9bd7..efc75edc 100644 --- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/AckWatermark.java +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/AckWatermark.java @@ -137,16 +137,23 @@ public void close() { */ public static AckWatermark open(String slotDir) { String filePath = slotDir + "/" + FILE_NAME; - // openCleanRW truncates, which would discard the previous - // session's watermark on every recovery and defeat the whole - // point. Decide by size: existing-and-correct -> openRW - // preserves the bytes; missing or wrong-sized -> openCleanRW - // creates/resizes (the resulting file has zero magic, which - // read() correctly reports as INVALID until the first write). + // Decide by size: existing-and-correct -> openRW preserves the + // previous session's watermark (defeating which is the whole + // point of NOT calling openCleanRW unconditionally); missing or + // wrong-sized -> openCleanRW + allocate creates a fresh + // FILE_SIZE-byte file (zero magic, read() reports INVALID until + // the first write). long existing = Files.exists(filePath) ? Files.length(filePath) : -1L; - int fd = existing == FILE_SIZE - ? Files.openRW(filePath) - : Files.openCleanRW(filePath, FILE_SIZE); + int fd; + if (existing == FILE_SIZE) { + fd = Files.openRW(filePath); + } else { + fd = Files.openCleanRW(filePath); + if (fd >= 0 && !Files.allocate(fd, FILE_SIZE)) { + Files.close(fd); + fd = -1; + } + } if (fd < 0) { LOG.warn("ack watermark {} could not be opened (rc={}); proceeding without it", filePath, fd); diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/MmapSegment.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/MmapSegment.java index 6584c085..162490c6 100644 --- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/MmapSegment.java +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/MmapSegment.java @@ -165,17 +165,15 @@ public static MmapSegment create(FilesFacade ff, long pathPtr, String displayPat throw new IllegalArgumentException( "sizeBytes too small for header + one minimal frame: " + sizeBytes); } - int fd = ff.openCleanRW(pathPtr, sizeBytes); + int fd = ff.openCleanRW(pathPtr); if (fd < 0) { throw new MmapSegmentException("openCleanRW failed for " + displayPath); } - // Reserve real disk blocks so ENOSPC surfaces here, before the - // producer thread starts writing frames into the mapping. The - // openCleanRW call above only sets the logical file size via - // ftruncate; the blocks remain sparse until something writes them. - // Calling allocate immediately after promotes ENOSPC from a - // SIGBUS-on-mmap-store (which aborts the JVM) to a clean failure - // path the caller can recover from. + // Reserve real disk blocks and advance EOF to sizeBytes in one + // call. ENOSPC surfaces here, before the producer thread starts + // writing frames into the mapping — a clean false return + // instead of a SIGBUS-on-mmap-store later (which would abort + // the JVM). if (!ff.allocate(fd, sizeBytes)) { ff.close(fd); // Unlink the partially-created file so a sf_max_bytes-sized diff --git a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SegmentManager.java b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SegmentManager.java index 82b81cbf..b817fa84 100644 --- a/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SegmentManager.java +++ b/core/src/main/java/io/questdb/client/cutlass/qwp/client/sf/cursor/SegmentManager.java @@ -40,7 +40,7 @@ * Background worker that keeps every registered {@link SegmentRing} supplied * with a hot-spare segment and trims segments after their frames have been * ACK'd by the server. Off the user-thread / I/O-thread hot path entirely: - * the expensive {@code openCleanRW + truncate + mmap} for spare creation and + * the expensive {@code openCleanRW + allocate + mmap} for spare creation and * {@code munmap + unlink} for trim happen on this thread, never on the * latency-sensitive paths. *
diff --git a/core/src/main/java/io/questdb/client/std/DefaultFilesFacade.java b/core/src/main/java/io/questdb/client/std/DefaultFilesFacade.java index e8b06075..d9b4b614 100644 --- a/core/src/main/java/io/questdb/client/std/DefaultFilesFacade.java +++ b/core/src/main/java/io/questdb/client/std/DefaultFilesFacade.java @@ -102,13 +102,13 @@ public int mkdir(String path, int mode) { } @Override - public int openCleanRW(String path, long size) { - return Files.openCleanRW(path, size); + public int openCleanRW(String path) { + return Files.openCleanRW(path); } @Override - public int openCleanRW(long pathPtr, long size) { - return Files.openCleanRW(pathPtr, size); + public int openCleanRW(long pathPtr) { + return Files.openCleanRW(pathPtr); } @Override diff --git a/core/src/main/java/io/questdb/client/std/Files.java b/core/src/main/java/io/questdb/client/std/Files.java index 681d68b5..02756c37 100644 --- a/core/src/main/java/io/questdb/client/std/Files.java +++ b/core/src/main/java/io/questdb/client/std/Files.java @@ -171,28 +171,29 @@ public static int openAppend(String path) { /** * Opens {@code path} for read-write access, truncating any existing - * content (mode 0644). When {@code size > 0} the new file is extended - * to exactly {@code size} bytes via {@code ftruncate}; when {@code size} - * is 0 the file is left empty. Returns a non-negative fd on success or - * -1 on failure (e.g. truncate failed due to ENOSPC). + * content (mode 0644). The new file is empty; callers that need a + * specific size should follow this with {@link #allocate(int, long)} + * (reserves real disk blocks, fails synchronously on ENOSPC) or + * {@link #truncate(int, long)} (sets logical size only, leaves blocks + * sparse). Returns a non-negative fd on success or -1 on failure. */ - public static int openCleanRW(String path, long size) { + public static int openCleanRW(String path) { long ptr = pathPtr(path); try { - return openCleanRW0(ptr, size); + return openCleanRW0(ptr); } finally { freePathPtr(ptr); } } /** - * Variant of {@link #openCleanRW(String, long)} taking a pre-encoded + * Variant of {@link #openCleanRW(String)} taking a pre-encoded * native UTF-8 path pointer; lets callers cache the encoded path and * skip the per-call {@code byte[]} + native-malloc that * {@link #pathPtr(String)} incurs. */ - public static int openCleanRW(long pathPtr, long size) { - return openCleanRW0(pathPtr, size); + public static int openCleanRW(long pathPtr) { + return openCleanRW0(pathPtr); } /** @@ -415,16 +416,60 @@ public static String utf8ToString(long nameZ) { public static native boolean truncate(int fd, long size); /** - * Reserves disk blocks for the file up to {@code size} bytes. On Linux - * uses {@code posix_fallocate}; on macOS uses {@code F_PREALLOCATE} - * with {@code F_ALLOCATEALL}; on Windows uses - * {@code SetFileInformationByHandle(FileAllocationInfo)}, which on - * NTFS reserves clusters synchronously and fails with - * {@code ERROR_DISK_FULL} when free space is insufficient. Never - * shrinks the file: requests smaller than the current logical size - * are rounded up. Falls back to {@code ftruncate} on Linux/macOS if - * pre-allocation isn't supported by the underlying filesystem (in - * which case the logical size is set but blocks remain sparse). + * Extends the file to at least {@code size} bytes and reserves real + * disk blocks for the newly-extended range. Lets the caller catch + * {@code ENOSPC} as a clean {@code false} return at this call site + * rather than as a runtime SIGBUS (POSIX) or in-page exception + * (Windows) on a later store into an mmap'd region. + * + *
Cross-platform contract — identical observable behaviour on + * Linux, macOS, and Windows for any caller that does not + * deliberately produce sparse files: + *
Per-platform primitives (provided for the curious; not part + * of the observable contract above): + *
Test injection point: a wrapping facade can return {@code false} + * to simulate disk-full at create time so the caller's recovery + * path is exercised. Callers that observe {@code false} MUST close + * the fd and unlink the partial file — the partially-extended file + * would otherwise occupy up to {@code max(size, currentSize)} bytes + * on disk. Default delegates to {@link Files#allocate(int, long)}. */ boolean allocate(int fd, long size); @@ -87,15 +91,15 @@ public interface FilesFacade { int mkdir(String path, int mode); - int openCleanRW(String path, long size); + int openCleanRW(String path); /** - * Variant of {@link #openCleanRW(String, long)} taking a pre-encoded + * Variant of {@link #openCleanRW(String)} taking a pre-encoded * native UTF-8 path pointer; lets callers in hot paths cache the encoded * path (e.g. via a reused {@code DirectUtf8Sink}) and skip the per-call * {@code byte[]} + native-malloc allocation. */ - int openCleanRW(long pathPtr, long size); + int openCleanRW(long pathPtr); int openRW(String path); diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/AckWatermarkTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/AckWatermarkTest.java index b4a0e141..001f62ce 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/AckWatermarkTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/AckWatermarkTest.java @@ -142,8 +142,9 @@ public void testStaleFileWithWrongSizeIsResetOnOpen() throws Exception { // write. TestUtils.assertMemoryLeak(() -> { String path = slotDir + "/" + AckWatermark.FILE_NAME; - int fd = Files.openCleanRW(path, 4); + int fd = Files.openCleanRW(path); assertTrue(fd >= 0); + assertTrue(Files.truncate(fd, 4)); Files.close(fd); assertEquals("precondition: file exists at wrong size", 4L, Files.length(path)); diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/MmapSegmentTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/MmapSegmentTest.java index 34be6745..42da6526 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/MmapSegmentTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/MmapSegmentTest.java @@ -326,7 +326,7 @@ public void testOpenExistingRejectsCorruptHeader() throws Exception { TestUtils.assertMemoryLeak(() -> { String path = tmpDir + "/seg-bad-magic.sfa"; // Build a file with the right size but the wrong magic. - int fd = Files.openCleanRW(path, MmapSegment.HEADER_SIZE); + int fd = Files.openCleanRW(path); long bufHdr = Unsafe.malloc(MmapSegment.HEADER_SIZE, MemoryTag.NATIVE_DEFAULT); try { Unsafe.getUnsafe().putInt(bufHdr, 0xBAD0FACE); @@ -596,21 +596,21 @@ public int mkdir(String path, int mode) { } @Override - public int openCleanRW(String path, long size) { + public int openCleanRW(String path) { openCleanRWCalls++; if (failOnOpenCleanRW) { return -1; } - return INSTANCE.openCleanRW(path, size); + return INSTANCE.openCleanRW(path); } @Override - public int openCleanRW(long pathPtr, long size) { + public int openCleanRW(long pathPtr) { openCleanRWCalls++; if (failOnOpenCleanRW) { return -1; } - return INSTANCE.openCleanRW(pathPtr, size); + return INSTANCE.openCleanRW(pathPtr); } @Override diff --git a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentRingTest.java b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentRingTest.java index cec666a2..0043e521 100644 --- a/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentRingTest.java +++ b/core/src/test/java/io/questdb/client/test/cutlass/qwp/client/sf/cursor/SegmentRingTest.java @@ -370,7 +370,7 @@ public void testOpenExistingSkipsBadMagicFile() throws Exception { s0.tryAppend(buf, 16); s0.close(); // One stray .sfa with no proper header — must be ignored. - int fd = Files.openCleanRW(tmpDir + "/stray.sfa", 64); + int fd = Files.openCleanRW(tmpDir + "/stray.sfa"); long hdr = Unsafe.malloc(8, MemoryTag.NATIVE_DEFAULT); try { Unsafe.getUnsafe().putLong(hdr, 0xBADBADBADBADBADBL); diff --git a/core/src/test/java/io/questdb/client/test/std/FilesTest.java b/core/src/test/java/io/questdb/client/test/std/FilesTest.java index 2c69c362..ebef927a 100644 --- a/core/src/test/java/io/questdb/client/test/std/FilesTest.java +++ b/core/src/test/java/io/questdb/client/test/std/FilesTest.java @@ -77,7 +77,7 @@ public void tearDown() { public void testWriteReadRoundtrip() throws Exception { TestUtils.assertMemoryLeak(() -> { String path = tmpDir + "/test.bin"; - int fd = Files.openCleanRW(path, 0); + int fd = Files.openCleanRW(path); assertTrue("expected fd >= 0, got " + fd, fd >= 0); try { long buf = Unsafe.malloc(8, MemoryTag.NATIVE_DEFAULT); @@ -109,8 +109,9 @@ public void testWriteReadRoundtrip() throws Exception { public void testTruncate() throws Exception { TestUtils.assertMemoryLeak(() -> { String path = tmpDir + "/trunc.bin"; - int fd = Files.openCleanRW(path, 1024); + int fd = Files.openCleanRW(path); try { + assertTrue(Files.truncate(fd, 1024)); assertEquals(1024, Files.length(fd)); assertTrue(Files.truncate(fd, 0)); assertEquals(0, Files.length(fd)); @@ -136,6 +137,55 @@ public void testAllocate() throws Exception { }); } + /** Pins the cross-platform contract on `Files.allocate`: + * never-shrinks, short-circuits on size <= currentSize, extends on + * size > currentSize. All four assertions must hold identically on + * Linux, macOS, and Windows — see Files.allocate javadoc. */ + @Test + public void testAllocateNeverShrinks() throws Exception { + TestUtils.assertMemoryLeak(() -> { + String path = tmpDir + "/alloc-shrink.bin"; + int fd = Files.openRW(path); + try { + // Grow to 64 KiB. + assertTrue(Files.allocate(fd, 65536)); + assertEquals(65536, Files.length(fd)); + + // Smaller request: must not shrink the file. + assertTrue(Files.allocate(fd, 4096)); + assertEquals(65536, Files.length(fd)); + + // Equal request: no-op success, size unchanged. + assertTrue(Files.allocate(fd, 65536)); + assertEquals(65536, Files.length(fd)); + + // Larger request: extends to the new target. + assertTrue(Files.allocate(fd, 131072)); + assertEquals(131072, Files.length(fd)); + } finally { + Files.close(fd); + } + }); + } + + /** A size=0 allocate on a fresh file is a no-op success — exercises + * the same short-circuit as testAllocateNeverShrinks but with the + * edge case of an empty file (no fallocate / F_PREALLOCATE syscall + * should reach the kernel). */ + @Test + public void testAllocateZeroOnFreshFile() throws Exception { + TestUtils.assertMemoryLeak(() -> { + String path = tmpDir + "/alloc-zero.bin"; + int fd = Files.openRW(path); + try { + assertTrue(Files.allocate(fd, 0)); + assertEquals(0, Files.length(fd)); + } finally { + Files.close(fd); + } + }); + } + @Test public void testAppend() throws Exception { TestUtils.assertMemoryLeak(() -> { @@ -162,7 +212,7 @@ public void testRename() throws Exception { TestUtils.assertMemoryLeak(() -> { String a = tmpDir + "/a"; String b = tmpDir + "/b"; - int fd = Files.openCleanRW(a, 0); + int fd = Files.openCleanRW(a); Files.close(fd); assertTrue(Files.exists(a)); assertEquals(0, Files.rename(a, b)); @@ -176,7 +226,7 @@ public void testFindFirstIteratesAllEntries() throws Exception { TestUtils.assertMemoryLeak(() -> { String[] names = {"alpha", "beta", "gamma"}; for (String n : names) { - int fd = Files.openCleanRW(tmpDir + "/" + n, 0); + int fd = Files.openCleanRW(tmpDir + "/" + n); Files.close(fd); } long find = Files.findFirst(tmpDir); @@ -207,7 +257,7 @@ public void testFindFirstIteratesAllEntries() throws Exception { public void testLockExclusive() throws Exception { TestUtils.assertMemoryLeak(() -> { String path = tmpDir + "/lock.bin"; - int fd1 = Files.openCleanRW(path, 0); + int fd1 = Files.openCleanRW(path); int fd2 = Files.openRW(path); try { assertEquals(0, Files.lock(fd1)); @@ -224,7 +274,7 @@ public void testExistsAndRemove() throws Exception { TestUtils.assertMemoryLeak(() -> { String path = tmpDir + "/x"; assertFalse(Files.exists(path)); - int fd = Files.openCleanRW(path, 0); + int fd = Files.openCleanRW(path); Files.close(fd); assertTrue(Files.exists(path)); assertTrue(Files.remove(path)); @@ -243,8 +293,9 @@ public void testPageSizeIsSane() { public void testMmapRoundtrip() throws Exception { TestUtils.assertMemoryLeak(() -> { String path = tmpDir + "/mmap.bin"; - int fd = Files.openCleanRW(path, 8192); + int fd = Files.openCleanRW(path); try { + assertTrue(Files.allocate(fd, 8192)); long addr = Files.mmap(fd, 8192, 0, Files.MAP_RW, MemoryTag.MMAP_DEFAULT); assertNotEquals("mmap returned FAILED", Files.FAILED_MMAP_ADDRESS, addr); try {