Skip to content

Commit 8d1cd25

Browse files
michalharakalclaude
andcommitted
feat(gguf): implement createRandomAccessSource on Kotlin/Native via pread
Adds a POSIX-pread-backed RandomAccessSource so K/N consumers can read GGUF files of arbitrary size instead of slurping into a single ByteArray (capped at ~2 GiB / Int.MAX_VALUE bytes). pread is positional and atomic, so concurrent reads are safe without locking. - New PosixPreadRandomAccessSource in skainet-io-core's nativeMain (covers macosArm64, linuxX64, linuxArm64, iosArm64, iosSimulatorArm64). - skainet-io-gguf's nativeMain factory delegates to it instead of returning null. Behaviour matches the JVM actual: returns null on open/stat failure so callers cleanly fall back to the legacy reader. - 11 nativeTest cases covering size, partial reads, offset/length variants, EOF/argument validation, idempotent close, and missing-file null return. Fixes #589 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent a4b38ed commit 8d1cd25

3 files changed

Lines changed: 237 additions & 6 deletions

File tree

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
package sk.ainet.io
2+
3+
import kotlinx.cinterop.ExperimentalForeignApi
4+
import kotlinx.cinterop.addressOf
5+
import kotlinx.cinterop.alloc
6+
import kotlinx.cinterop.convert
7+
import kotlinx.cinterop.memScoped
8+
import kotlinx.cinterop.ptr
9+
import kotlinx.cinterop.toKString
10+
import kotlinx.cinterop.usePinned
11+
import platform.posix.O_RDONLY
12+
import platform.posix.errno
13+
import platform.posix.fstat
14+
import platform.posix.pread
15+
import platform.posix.stat
16+
import platform.posix.strerror
17+
18+
/**
19+
* Native [RandomAccessSource] backed by POSIX `pread(2)`.
20+
*
21+
* `pread` is positional and atomic — it does not advance any shared seek
22+
* pointer — so concurrent reads from different positions are safe without
23+
* locking. [close] is single-shot.
24+
*
25+
* Used on macOS, iOS, and Linux native targets (which all share the
26+
* `nativeMain` source set in this module). Android uses a separate JNI
27+
* actual; JS / Wasm don't have a viable `pread` equivalent and continue
28+
* to fall back to the legacy GGUF reader.
29+
*/
30+
@OptIn(ExperimentalForeignApi::class)
31+
public class PosixPreadRandomAccessSource private constructor(
32+
private val fd: Int,
33+
override val size: Long
34+
) : RandomAccessSource {
35+
36+
private var closed = false
37+
38+
override fun readAt(position: Long, length: Int): ByteArray {
39+
require(position >= 0) { "Position must be non-negative: $position" }
40+
require(length >= 0) { "Length must be non-negative: $length" }
41+
require(position + length <= size) {
42+
"Read beyond end of file: position=$position, length=$length, size=$size"
43+
}
44+
if (length == 0) return ByteArray(0)
45+
46+
val buffer = ByteArray(length)
47+
val bytesRead = readAt(position, buffer, 0, length)
48+
return if (bytesRead < length) buffer.copyOf(bytesRead) else buffer
49+
}
50+
51+
override fun readAt(position: Long, buffer: ByteArray, offset: Int, length: Int): Int {
52+
require(position >= 0) { "Position must be non-negative: $position" }
53+
require(offset >= 0) { "Offset must be non-negative: $offset" }
54+
require(length >= 0) { "Length must be non-negative: $length" }
55+
require(offset + length <= buffer.size) {
56+
"Buffer overflow: offset=$offset, length=$length, buffer.size=${buffer.size}"
57+
}
58+
check(!closed) { "Source is closed" }
59+
if (length == 0) return 0
60+
61+
return buffer.usePinned { pinned ->
62+
var totalRead = 0
63+
while (totalRead < length) {
64+
val n = pread(
65+
fd,
66+
pinned.addressOf(offset + totalRead),
67+
(length - totalRead).convert(),
68+
(position + totalRead).convert()
69+
).toInt()
70+
if (n < 0) {
71+
val cause = strerror(errno)?.toKString() ?: "errno=$errno"
72+
error("pread failed at offset ${position + totalRead}: $cause")
73+
}
74+
if (n == 0) break // EOF
75+
totalRead += n
76+
}
77+
totalRead
78+
}
79+
}
80+
81+
override fun close() {
82+
if (closed) return
83+
closed = true
84+
platform.posix.close(fd)
85+
}
86+
87+
public companion object {
88+
/**
89+
* Open [path] for read-only random access. Returns `null` if the
90+
* file cannot be opened or stat'd — matches [JvmRandomAccessSource]
91+
* behaviour, letting consumers fall back to the legacy reader.
92+
*/
93+
public fun open(path: String): PosixPreadRandomAccessSource? = memScoped {
94+
val fd = platform.posix.open(path, O_RDONLY)
95+
if (fd < 0) return@memScoped null
96+
val st = alloc<stat>()
97+
if (fstat(fd, st.ptr) != 0) {
98+
platform.posix.close(fd)
99+
return@memScoped null
100+
}
101+
PosixPreadRandomAccessSource(fd, st.st_size.toLong())
102+
}
103+
}
104+
}
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
package sk.ainet.io
2+
3+
import kotlinx.io.buffered
4+
import kotlinx.io.files.Path
5+
import kotlinx.io.files.SystemFileSystem
6+
import kotlinx.io.files.SystemTemporaryDirectory
7+
import kotlinx.io.write
8+
import kotlin.test.AfterTest
9+
import kotlin.test.BeforeTest
10+
import kotlin.test.Test
11+
import kotlin.test.assertContentEquals
12+
import kotlin.test.assertEquals
13+
import kotlin.test.assertFailsWith
14+
import kotlin.test.assertNull
15+
import kotlin.test.assertTrue
16+
17+
class PosixPreadRandomAccessSourceTest {
18+
19+
private val expected = ByteArray(8192) { (it and 0xFF).toByte() } // 0..255 repeating
20+
private lateinit var path: Path
21+
22+
@BeforeTest
23+
fun setUp() {
24+
path = Path(SystemTemporaryDirectory, "pread-test-${kotlin.random.Random.nextLong()}.bin")
25+
SystemFileSystem.sink(path).buffered().use { it.write(expected) }
26+
}
27+
28+
@AfterTest
29+
fun tearDown() {
30+
if (SystemFileSystem.exists(path)) SystemFileSystem.delete(path)
31+
}
32+
33+
@Test
34+
fun open_reports_correct_size() {
35+
val src = PosixPreadRandomAccessSource.open(path.toString())!!
36+
try {
37+
assertEquals(expected.size.toLong(), src.size)
38+
} finally {
39+
src.close()
40+
}
41+
}
42+
43+
@Test
44+
fun read_at_zero_returns_prefix() {
45+
PosixPreadRandomAccessSource.open(path.toString())!!.use { src ->
46+
val got = src.readAt(0, 16)
47+
assertContentEquals(expected.copyOfRange(0, 16), got)
48+
}
49+
}
50+
51+
@Test
52+
fun read_at_arbitrary_offset_returns_slice() {
53+
PosixPreadRandomAccessSource.open(path.toString())!!.use { src ->
54+
val got = src.readAt(1234, 256)
55+
assertContentEquals(expected.copyOfRange(1234, 1234 + 256), got)
56+
}
57+
}
58+
59+
@Test
60+
fun read_at_end_returns_suffix() {
61+
PosixPreadRandomAccessSource.open(path.toString())!!.use { src ->
62+
val got = src.readAt(expected.size - 32L, 32)
63+
assertContentEquals(expected.copyOfRange(expected.size - 32, expected.size), got)
64+
}
65+
}
66+
67+
@Test
68+
fun read_into_buffer_reports_bytes_read() {
69+
PosixPreadRandomAccessSource.open(path.toString())!!.use { src ->
70+
val buf = ByteArray(64)
71+
val n = src.readAt(100L, buf, 0, 64)
72+
assertEquals(64, n)
73+
assertContentEquals(expected.copyOfRange(100, 164), buf)
74+
}
75+
}
76+
77+
@Test
78+
fun read_into_buffer_with_offset() {
79+
PosixPreadRandomAccessSource.open(path.toString())!!.use { src ->
80+
val buf = ByteArray(128)
81+
val n = src.readAt(50L, buf, offset = 32, length = 64)
82+
assertEquals(64, n)
83+
assertContentEquals(expected.copyOfRange(50, 114), buf.copyOfRange(32, 96))
84+
// Bytes outside the requested window must remain zero.
85+
for (i in 0 until 32) assertEquals(0, buf[i])
86+
for (i in 96 until 128) assertEquals(0, buf[i])
87+
}
88+
}
89+
90+
@Test
91+
fun read_past_end_throws() {
92+
PosixPreadRandomAccessSource.open(path.toString())!!.use { src ->
93+
assertFailsWith<IllegalArgumentException> { src.readAt(expected.size - 1L, 16) }
94+
}
95+
}
96+
97+
@Test
98+
fun negative_position_throws() {
99+
PosixPreadRandomAccessSource.open(path.toString())!!.use { src ->
100+
assertFailsWith<IllegalArgumentException> { src.readAt(-1L, 4) }
101+
}
102+
}
103+
104+
@Test
105+
fun read_after_close_throws() {
106+
val src = PosixPreadRandomAccessSource.open(path.toString())!!
107+
src.close()
108+
assertFailsWith<IllegalStateException> {
109+
src.readAt(0L, ByteArray(4), 0, 4)
110+
}
111+
}
112+
113+
@Test
114+
fun close_is_idempotent() {
115+
val src = PosixPreadRandomAccessSource.open(path.toString())!!
116+
src.close()
117+
src.close() // must not throw
118+
assertTrue(true)
119+
}
120+
121+
@Test
122+
fun open_missing_file_returns_null() {
123+
val missing = Path(SystemTemporaryDirectory, "definitely-does-not-exist-${kotlin.random.Random.nextLong()}.bin")
124+
assertNull(PosixPreadRandomAccessSource.open(missing.toString()))
125+
}
126+
}
Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
package sk.ainet.io.gguf
22

3+
import sk.ainet.io.PosixPreadRandomAccessSource
34
import sk.ainet.io.RandomAccessSource
45

56
/**
6-
* Native implementation of [createRandomAccessSource].
7+
* Native implementation of [createRandomAccessSource] using POSIX `pread(2)`.
78
*
8-
* Returns null as native random file access is not yet implemented.
9-
* Callers should fall back to legacy GGUFReader which loads the full file.
10-
*
11-
* Future: Could implement using POSIX pread() for efficient random access.
9+
* Returns `null` if the file cannot be opened (missing, permission denied,
10+
* etc.), matching the JVM actual's contract so callers can fall back to the
11+
* legacy sequential reader.
1212
*/
13-
public actual fun createRandomAccessSource(filePath: String): RandomAccessSource? = null
13+
public actual fun createRandomAccessSource(filePath: String): RandomAccessSource? =
14+
PosixPreadRandomAccessSource.open(filePath)

0 commit comments

Comments
 (0)