diff --git a/skainet-io/skainet-io-core/src/mingwX64Main/kotlin/sk/ainet/io/WindowsRandomAccessSource.kt b/skainet-io/skainet-io-core/src/mingwX64Main/kotlin/sk/ainet/io/WindowsRandomAccessSource.kt new file mode 100644 index 000000000..1c801d707 --- /dev/null +++ b/skainet-io/skainet-io-core/src/mingwX64Main/kotlin/sk/ainet/io/WindowsRandomAccessSource.kt @@ -0,0 +1,134 @@ +package sk.ainet.io + +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.addressOf +import kotlinx.cinterop.alloc +import kotlinx.cinterop.convert +import kotlinx.cinterop.memScoped +import kotlinx.cinterop.ptr +import kotlinx.cinterop.usePinned +import kotlinx.cinterop.value +import platform.windows.CloseHandle +import platform.windows.CreateFileW +import platform.windows.DWORDVar +import platform.windows.ERROR_HANDLE_EOF +import platform.windows.FILE_ATTRIBUTE_NORMAL +import platform.windows.FILE_SHARE_READ +import platform.windows.GENERIC_READ +import platform.windows.GetFileSizeEx +import platform.windows.GetLastError +import platform.windows.HANDLE +import platform.windows.INVALID_HANDLE_VALUE +import platform.windows.LARGE_INTEGER +import platform.windows.OPEN_EXISTING +import platform.windows.OVERLAPPED +import platform.windows.ReadFile + +/** + * Windows [RandomAccessSource] backed by `ReadFile` with an `OVERLAPPED` offset. + * + * POSIX `pread(2)` does not exist on mingw; passing an `OVERLAPPED` structure with an + * explicit 64-bit offset to `ReadFile` gives the same positional semantics — every call + * names its own offset, so concurrent reads from different positions are safe without + * locking, and files > 2 GB work (offset is split into `Offset`/`OffsetHigh`). + * + * This is deliberately a separate leaf implementation: `mingwX64Main` must stay out of + * this module's `native64Main` source set (that set is POSIX-`pread`-shaped and LP64; + * mingw is LLP64). [close] is single-shot. See #911. + */ +@OptIn(ExperimentalForeignApi::class) +public class WindowsRandomAccessSource private constructor( + private val handle: HANDLE, + override val size: Long, +) : RandomAccessSource { + + private var closed = false + + override fun readAt(position: Long, length: Int): ByteArray { + require(position >= 0) { "Position must be non-negative: $position" } + require(length >= 0) { "Length must be non-negative: $length" } + require(position + length <= size) { + "Read beyond end of file: position=$position, length=$length, size=$size" + } + if (length == 0) return ByteArray(0) + + val buffer = ByteArray(length) + val bytesRead = readAt(position, buffer, 0, length) + return if (bytesRead < length) buffer.copyOf(bytesRead) else buffer + } + + override fun readAt(position: Long, buffer: ByteArray, offset: Int, length: Int): Int { + require(position >= 0) { "Position must be non-negative: $position" } + require(offset >= 0) { "Offset must be non-negative: $offset" } + require(length >= 0) { "Length must be non-negative: $length" } + require(offset + length <= buffer.size) { + "Buffer overflow: offset=$offset, length=$length, buffer.size=${buffer.size}" + } + check(!closed) { "Source is closed" } + if (length == 0) return 0 + + return buffer.usePinned { pinned -> + var totalRead = 0 + while (totalRead < length) { + val chunk = memScoped { + val pos = position + totalRead + val overlapped = alloc() + overlapped.Offset = (pos and 0xFFFF_FFFFL).toUInt() + overlapped.OffsetHigh = (pos ushr 32).toUInt() + val read = alloc() + val ok = ReadFile( + handle, + pinned.addressOf(offset + totalRead), + (length - totalRead).convert(), + read.ptr, + overlapped.ptr, + ) + if (ok == 0) { + val err = GetLastError() + if (err == ERROR_HANDLE_EOF.toUInt()) return@memScoped 0 + error("ReadFile failed at offset $pos: Win32 error $err") + } + read.value.toInt() + } + if (chunk == 0) break // EOF + totalRead += chunk + } + totalRead + } + } + + override fun close() { + if (closed) return + closed = true + CloseHandle(handle) + } + + public companion object { + /** + * Open [path] for read-only random access. Returns `null` if the file cannot be + * opened or sized — matching the JVM/POSIX implementations, so consumers fall + * back to the legacy sequential reader. + */ + public fun open(path: String): WindowsRandomAccessSource? { + val handle = CreateFileW( + path, + GENERIC_READ.convert(), + FILE_SHARE_READ.convert(), + null, + OPEN_EXISTING.convert(), + FILE_ATTRIBUTE_NORMAL.convert(), + null, + ) + if (handle == null || handle == INVALID_HANDLE_VALUE) return null + return memScoped { + val sizeVar = alloc() + if (GetFileSizeEx(handle, sizeVar.ptr) == 0) { + CloseHandle(handle) + null + } else { + WindowsRandomAccessSource(handle, sizeVar.QuadPart) + } + } + } + } +} diff --git a/skainet-io/skainet-io-core/src/mingwX64Test/kotlin/sk/ainet/io/WindowsRandomAccessSourceTest.kt b/skainet-io/skainet-io-core/src/mingwX64Test/kotlin/sk/ainet/io/WindowsRandomAccessSourceTest.kt new file mode 100644 index 000000000..c7283a5ee --- /dev/null +++ b/skainet-io/skainet-io-core/src/mingwX64Test/kotlin/sk/ainet/io/WindowsRandomAccessSourceTest.kt @@ -0,0 +1,126 @@ +package sk.ainet.io + +import kotlinx.io.buffered +import kotlinx.io.files.Path +import kotlinx.io.files.SystemFileSystem +import kotlinx.io.files.SystemTemporaryDirectory +import kotlinx.io.write +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class WindowsRandomAccessSourceTest { + + private val expected = ByteArray(8192) { (it and 0xFF).toByte() } // 0..255 repeating + private lateinit var path: Path + + @BeforeTest + fun setUp() { + path = Path(SystemTemporaryDirectory, "win-read-test-${kotlin.random.Random.nextLong()}.bin") + SystemFileSystem.sink(path).buffered().use { it.write(expected) } + } + + @AfterTest + fun tearDown() { + if (SystemFileSystem.exists(path)) SystemFileSystem.delete(path) + } + + @Test + fun open_reports_correct_size() { + val src = WindowsRandomAccessSource.open(path.toString())!! + try { + assertEquals(expected.size.toLong(), src.size) + } finally { + src.close() + } + } + + @Test + fun read_at_zero_returns_prefix() { + WindowsRandomAccessSource.open(path.toString())!!.use { src -> + val got = src.readAt(0, 16) + assertContentEquals(expected.copyOfRange(0, 16), got) + } + } + + @Test + fun read_at_arbitrary_offset_returns_slice() { + WindowsRandomAccessSource.open(path.toString())!!.use { src -> + val got = src.readAt(1234, 256) + assertContentEquals(expected.copyOfRange(1234, 1234 + 256), got) + } + } + + @Test + fun read_at_end_returns_suffix() { + WindowsRandomAccessSource.open(path.toString())!!.use { src -> + val got = src.readAt(expected.size - 32L, 32) + assertContentEquals(expected.copyOfRange(expected.size - 32, expected.size), got) + } + } + + @Test + fun read_into_buffer_reports_bytes_read() { + WindowsRandomAccessSource.open(path.toString())!!.use { src -> + val buf = ByteArray(64) + val n = src.readAt(100L, buf, 0, 64) + assertEquals(64, n) + assertContentEquals(expected.copyOfRange(100, 164), buf) + } + } + + @Test + fun read_into_buffer_with_offset() { + WindowsRandomAccessSource.open(path.toString())!!.use { src -> + val buf = ByteArray(128) + val n = src.readAt(50L, buf, offset = 32, length = 64) + assertEquals(64, n) + assertContentEquals(expected.copyOfRange(50, 114), buf.copyOfRange(32, 96)) + // Bytes outside the requested window must remain zero. + for (i in 0 until 32) assertEquals(0, buf[i]) + for (i in 96 until 128) assertEquals(0, buf[i]) + } + } + + @Test + fun read_past_end_throws() { + WindowsRandomAccessSource.open(path.toString())!!.use { src -> + assertFailsWith { src.readAt(expected.size - 1L, 16) } + } + } + + @Test + fun negative_position_throws() { + WindowsRandomAccessSource.open(path.toString())!!.use { src -> + assertFailsWith { src.readAt(-1L, 4) } + } + } + + @Test + fun read_after_close_throws() { + val src = WindowsRandomAccessSource.open(path.toString())!! + src.close() + assertFailsWith { + src.readAt(0L, ByteArray(4), 0, 4) + } + } + + @Test + fun close_is_idempotent() { + val src = WindowsRandomAccessSource.open(path.toString())!! + src.close() + src.close() // must not throw + assertTrue(true) + } + + @Test + fun open_missing_file_returns_null() { + val missing = Path(SystemTemporaryDirectory, "definitely-does-not-exist-${kotlin.random.Random.nextLong()}.bin") + assertNull(WindowsRandomAccessSource.open(missing.toString())) + } +} diff --git a/skainet-io/skainet-io-gguf/build.gradle.kts b/skainet-io/skainet-io-gguf/build.gradle.kts index 0d6b25956..308b54ccd 100644 --- a/skainet-io/skainet-io-gguf/build.gradle.kts +++ b/skainet-io/skainet-io-gguf/build.gradle.kts @@ -1,74 +1,50 @@ -import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl import org.jetbrains.kotlin.gradle.dsl.JvmTarget plugins { - alias(libs.plugins.kotlinMultiplatform) + id("sk.ainet.multiplatform") alias(libs.plugins.androidMultiplatformLibrary) alias(libs.plugins.vanniktech.mavenPublish) id("sk.ainet.dokka") } -kotlin { - targets.configureEach { - compilations.configureEach { - compileTaskProvider.get().compilerOptions { - freeCompilerArgs.add("-Xexpect-actual-classes") - } - } - } - - jvm() - android { - namespace = "sk.ainet.io.gguf" - compileSdk = libs.versions.android.compileSdk.get().toInt() - minSdk = libs.versions.android.minSdk.get().toInt() - compilerOptions { - jvmTarget.set(JvmTarget.JVM_1_8) - } - } - - iosArm64() - iosSimulatorArm64() - macosArm64 () - linuxX64 () - linuxArm64 () - - js { - browser() - } - - @OptIn(ExperimentalWasmDsl::class) - wasmJs { - browser() - } - - @OptIn(ExperimentalWasmDsl::class) - wasmWasi { - nodejs() - } +// Targets come from skainet.targets in this module's gradle.properties (mingw per #911: +// its createRandomAccessSource actual routes to io-core's WindowsRandomAccessSource). +// explicitApi(), kotlin-test and -Xexpect-actual-classes come from sk.ainet.multiplatform. +skainet { + namespace = "sk.ainet.io.gguf" + androidJvmTarget = JvmTarget.JVM_1_8 + expectActualClasses = true + // Pre-migration behavior: this module never enabled explicit API mode; turning it on + // is a separate cleanup from the #911 target work. + explicitApi = false +} +kotlin { sourceSets { - val commonMain by getting { - dependencies { - implementation(libs.kotlinx.io.core) - implementation(project(":skainet-lang:skainet-lang-core")) - implementation(project(":skainet-io:skainet-io-core")) - implementation(project(":skainet-compile:skainet-compile-core")) - implementation(project(":skainet-compile:skainet-compile-dag")) - - } - } - val commonTest by getting { - dependencies { - implementation(libs.kotlin.test) - } + // This module opts out of the default hierarchy template + // (kotlin.mpp.applyDefaultHierarchyTemplate=false in gradle.properties) — custom + // dependsOn edges would silently disable it anyway — and wires the native tree by + // hand: the posix `pread`-backed createRandomAccessSource actual is shared by the + // Apple and Linux targets via `posixMain`, while mingwX64 hangs off `nativeMain` + // directly with its own Win32-backed leaf actual (posix pread does not exist + // there). No apple/linux intermediates: no sources live at that level. + nativeMain { dependsOn(commonMain.get()) } + val posixMain by creating { dependsOn(nativeMain.get()) } + listOf(iosArm64Main, iosSimulatorArm64Main, macosArm64Main, linuxX64Main, linuxArm64Main) + .forEach { it.get().dependsOn(posixMain) } + mingwX64Main { dependsOn(nativeMain.get()) } + + commonMain.dependencies { + implementation(libs.kotlinx.io.core) + implementation(project(":skainet-lang:skainet-lang-core")) + implementation(project(":skainet-io:skainet-io-core")) + implementation(project(":skainet-compile:skainet-compile-core")) + implementation(project(":skainet-compile:skainet-compile-dag")) } - val jvmTest by getting { - dependencies { - implementation(libs.junit) - implementation(libs.kotlinx.coroutines) - implementation(libs.kotlinx.coroutines.test) - } + jvmTest.dependencies { + implementation(libs.junit) + implementation(libs.kotlinx.coroutines) + implementation(libs.kotlinx.coroutines.test) } } } diff --git a/skainet-io/skainet-io-gguf/gradle.properties b/skainet-io/skainet-io-gguf/gradle.properties index a7d40a9a5..ca2741c59 100644 --- a/skainet-io/skainet-io-gguf/gradle.properties +++ b/skainet-io/skainet-io-gguf/gradle.properties @@ -1,2 +1,4 @@ POM_ARTIFACT_ID=skainet-io-gguf -POM_NAME=skainet IO API \ No newline at end of file +POM_NAME=skainet IO API +skainet.targets=jvm,js,wasmJs,wasmWasi,apple,linux,mingw +kotlin.mpp.applyDefaultHierarchyTemplate=false diff --git a/skainet-io/skainet-io-gguf/src/mingwX64Main/kotlin/sk/ainet/io/gguf/RandomAccessSourceFactory.mingw.kt b/skainet-io/skainet-io-gguf/src/mingwX64Main/kotlin/sk/ainet/io/gguf/RandomAccessSourceFactory.mingw.kt new file mode 100644 index 000000000..f32ae9339 --- /dev/null +++ b/skainet-io/skainet-io-gguf/src/mingwX64Main/kotlin/sk/ainet/io/gguf/RandomAccessSourceFactory.mingw.kt @@ -0,0 +1,13 @@ +package sk.ainet.io.gguf + +import sk.ainet.io.RandomAccessSource +import sk.ainet.io.WindowsRandomAccessSource + +/** + * Windows implementation of [createRandomAccessSource]: `ReadFile` with an `OVERLAPPED` + * offset via io-core's [WindowsRandomAccessSource] (posix `pread` does not exist on + * mingw). Returns `null` if the file cannot be opened, matching the JVM/POSIX actuals' + * contract so callers can fall back to the legacy sequential reader. See #911. + */ +public actual fun createRandomAccessSource(filePath: String): RandomAccessSource? = + WindowsRandomAccessSource.open(filePath) diff --git a/skainet-io/skainet-io-gguf/src/posixMain/kotlin/sk/ainet/io/gguf/RandomAccessSourceFactory.posix.kt b/skainet-io/skainet-io-gguf/src/posixMain/kotlin/sk/ainet/io/gguf/RandomAccessSourceFactory.posix.kt new file mode 100644 index 000000000..5cd3b2086 --- /dev/null +++ b/skainet-io/skainet-io-gguf/src/posixMain/kotlin/sk/ainet/io/gguf/RandomAccessSourceFactory.posix.kt @@ -0,0 +1,14 @@ +package sk.ainet.io.gguf + +import sk.ainet.io.PosixPreadRandomAccessSource +import sk.ainet.io.RandomAccessSource + +/** + * Native implementation of [createRandomAccessSource] using POSIX `pread(2)`. + * + * Returns `null` if the file cannot be opened (missing, permission denied, + * etc.), matching the JVM actual's contract so callers can fall back to the + * legacy sequential reader. + */ +public actual fun createRandomAccessSource(filePath: String): RandomAccessSource? = + PosixPreadRandomAccessSource.open(filePath)