Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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>()
overlapped.Offset = (pos and 0xFFFF_FFFFL).toUInt()
overlapped.OffsetHigh = (pos ushr 32).toUInt()
val read = alloc<DWORDVar>()
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<LARGE_INTEGER>()
if (GetFileSizeEx(handle, sizeVar.ptr) == 0) {
CloseHandle(handle)
null
} else {
WindowsRandomAccessSource(handle, sizeVar.QuadPart)
}
}
}
}
}
Original file line number Diff line number Diff line change
@@ -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<IllegalArgumentException> { src.readAt(expected.size - 1L, 16) }
}
}

@Test
fun negative_position_throws() {
WindowsRandomAccessSource.open(path.toString())!!.use { src ->
assertFailsWith<IllegalArgumentException> { src.readAt(-1L, 4) }
}
}

@Test
fun read_after_close_throws() {
val src = WindowsRandomAccessSource.open(path.toString())!!
src.close()
assertFailsWith<IllegalStateException> {
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()))
}
}
96 changes: 36 additions & 60 deletions skainet-io/skainet-io-gguf/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
4 changes: 3 additions & 1 deletion skainet-io/skainet-io-gguf/gradle.properties
Original file line number Diff line number Diff line change
@@ -1,2 +1,4 @@
POM_ARTIFACT_ID=skainet-io-gguf
POM_NAME=skainet IO API
POM_NAME=skainet IO API
skainet.targets=jvm,js,wasmJs,wasmWasi,apple,linux,mingw
kotlin.mpp.applyDefaultHierarchyTemplate=false
Original file line number Diff line number Diff line change
@@ -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)
Loading
Loading