diff --git a/library/core/src/commonMain/kotlin/com/linroid/ketch/core/file/PathFileAccessor.kt b/library/core/src/commonMain/kotlin/com/linroid/ketch/core/file/PathFileAccessor.kt index 38a812f..d81f0db 100644 --- a/library/core/src/commonMain/kotlin/com/linroid/ketch/core/file/PathFileAccessor.kt +++ b/library/core/src/commonMain/kotlin/com/linroid/ketch/core/file/PathFileAccessor.kt @@ -71,9 +71,23 @@ internal class PathFileAccessor( } override suspend fun preallocate(size: Long) { + if (size <= 0) return log.d { "Preallocating $size bytes: $realPath" } withContext(dispatcher) { - getOrCreateHandle().resize(size) + val handle = getOrCreateHandle() + val current = handle.size() + when { + size > current -> { + // okio 3.16.4 JvmFileHandle.protectedResize() grows by + // allocating ByteArray((size - current).toInt()) and writing + // it whole — this overflows Int and crashes with + // NegativeArraySizeException once delta exceeds 2 GB. Extend + // via a single sparse-byte write to avoid the bug and the + // gratuitous allocation. + handle.write(size - 1, byteArrayOf(0), 0, 1) + } + size < current -> handle.resize(size) + } } } } diff --git a/library/core/src/jvmTest/kotlin/com/linroid/ketch/file/PathFileAccessorPreallocateTest.kt b/library/core/src/jvmTest/kotlin/com/linroid/ketch/file/PathFileAccessorPreallocateTest.kt new file mode 100644 index 0000000..d179cff --- /dev/null +++ b/library/core/src/jvmTest/kotlin/com/linroid/ketch/file/PathFileAccessorPreallocateTest.kt @@ -0,0 +1,74 @@ +package com.linroid.ketch.file + +import com.linroid.ketch.core.file.PathFileAccessor +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.test.runTest +import java.nio.file.Files +import kotlin.io.path.absolutePathString +import kotlin.io.path.deleteIfExists +import kotlin.io.path.fileSize +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * Regression tests for [PathFileAccessor.preallocate] — particularly + * around files larger than 2 GB. okio 3.16.4's `JvmFileHandle.protectedResize` + * allocates a `ByteArray(delta.toInt())` when growing a file, which throws + * `NegativeArraySizeException` once `delta` exceeds `Int.MAX_VALUE`. We + * extend via a single sparse write instead. + * + * Files are created sparse, so the disk footprint is one filesystem block, + * not the logical size. + */ +class PathFileAccessorPreallocateTest { + + private fun tempPath(): String { + val dir = Files.createTempDirectory("ketch-preallocate-test") + return dir.resolve("file.bin").absolutePathString() + } + + @Test + fun preallocate_smallFile() = runTest { + val path = tempPath() + val accessor = PathFileAccessor(path, Dispatchers.IO) + try { + accessor.preallocate(1024L) + assertEquals(1024L, java.nio.file.Path.of(path).fileSize()) + } finally { + accessor.close() + java.nio.file.Path.of(path).deleteIfExists() + } + } + + @Test + fun preallocate_above2GB_doesNotOverflow() = runTest { + val path = tempPath() + val size = 2_918_598_656L // ~2.72 GB, matches the reported failure + val accessor = PathFileAccessor(path, Dispatchers.IO) + try { + accessor.preallocate(size) + assertEquals(size, java.nio.file.Path.of(path).fileSize()) + } finally { + accessor.close() + java.nio.file.Path.of(path).deleteIfExists() + } + } + + @Test + fun preallocate_zero_isNoOp() = runTest { + val path = tempPath() + val accessor = PathFileAccessor(path, Dispatchers.IO) + try { + accessor.preallocate(0L) + // No file written; size() opens the handle lazily — verify the + // file system path has no file or has size zero. + val p = java.nio.file.Path.of(path) + if (java.nio.file.Files.exists(p)) { + assertEquals(0L, p.fileSize()) + } + } finally { + accessor.close() + java.nio.file.Path.of(path).deleteIfExists() + } + } +}