From 045ad896f425e4c02ffe7d21965b82bff331aa64 Mon Sep 17 00:00:00 2001 From: Luke Curley <432854+kixelated@users.noreply.github.com> Date: Mon, 2 Nov 2026 03:05:37 -0800 Subject: [PATCH 1/3] feat(ffi): read Kotlin's bytes only through methods, freed by the GC Co-Authored-By: Claude Opus 5.5 --- ffi/MIGRATE.md | 16 +-- ffi/README.md | 16 ++- ffi/moq/bench/export.kt | 33 +++++- ffi/moq/moq.kt | 10 +- ffi/runtime/jni.rs | 73 ++++++++---- ffi/runtime/rt.kt | 39 ++++--- ffi/src/kotlin.rs | 92 +++++++++------ ffi/tests/api.kt | 185 +++++++++++++++++++++++++------ quest/m1/ffi-export/lifetimes.md | 9 +- 9 files changed, 340 insertions(+), 133 deletions(-) diff --git a/ffi/MIGRATE.md b/ffi/MIGRATE.md index 795ad281b..bf71dc61f 100644 --- a/ffi/MIGRATE.md +++ b/ffi/MIGRATE.md @@ -14,13 +14,15 @@ name the Swift uses, such as `Data`, takes a `rename`. - Every export writes `go/.go`. An item whose Go name clashes with another's, or with `Bound`, `Bytes`, `NewBytes`, `ErrClosed`, or a `Tuple` the Go declares, takes a `rename`. -- Kotlin returns bytes as a `Bytes`, not a `ByteArray`: Rust's buffer as `bytes.buffer`, a - read-only `ByteBuffer`, copied up to 1 KiB and otherwise a direct one lent uncopied - until `bytes.close()` frees it. Read it in `use { }`, or copy it out with - `toByteArray()`. A record's bytes field is a `Bytes` too: build one to pass in with - `Bytes(byteArray)`. Arguments stay `ByteArray`. Kotlin's objects and `Bytes` never - closed are freed once the GC collects them, and an exported type named `bytes` clashes - with the runtime's `_bytes`, which takes a `rename`. +- Kotlin returns bytes as a `Bytes`, not a `ByteArray`, read through `size`, `get`, + `slice`, `copyInto`, and `toByteArray()`. It has no `buffer` and no `close()`, so no + `ByteBuffer` over Rust's memory outlives it: drop `use { }` and read `bytes.buffer`'s + contents through these instead, such as `bytes.toByteArray()` or `bytes[0]`. Rust's + buffer is freed once the GC finds no `Bytes` reaching it, its slices included. A + record's bytes field is a `Bytes` too: build one to pass in with `Bytes(byteArray)`. + Arguments stay `ByteArray`. Kotlin's objects never closed are freed once the GC + collects them, and an exported type named `bytes` clashes with the runtime's + `_bytes`, which takes a `rename`. - A type parameter bounded by serde's `Serialize` or `Deserialize` takes `serde_json::Value`, not `String`: C passes a `_json` it parses from JSON text with `_json_parse`, not a buffer, and a generic type at such a parameter is diff --git a/ffi/README.md b/ffi/README.md index be9946b15..1dc61fd97 100644 --- a/ffi/README.md +++ b/ffi/README.md @@ -246,10 +246,12 @@ in as a `ByteArray`, bytes returned as their owner, and anything else encoded in or for one with data a sealed class of a data class per variant. `Display` is `toString()`, `Debug` `debug()`, and `Default` a companion's `default()`. - **Bytes** passed in are a `ByteArray`, copied, as are those Rust lends a closure or trait - method. Bytes Rust returns, or a record holds, are a `Bytes`, a read-only `ByteBuffer`: - up to 1 KiB copied, and longer ones lending Rust's buffer as a direct one until `close()` - frees it, or, never closed, until the GC collects that buffer, since a copy undercuts - `NewDirectByteBuffer` only that far; `Bytes(ByteArray)` holds Kotlin's for a record + method. Bytes Rust returns, or a record holds, are a `Bytes`, read only through `size`, + `get`, `slice`, `copyInto`, and `toByteArray()`, so no view of Rust's memory escapes and + no read meets it freed. Up to 1 KiB are copied, which costs less than the GC's cleanup; + longer ones stay in Rust's buffer, each read a JNI call that passes the buffer's owner, + which JNI keeps reachable until the call returns, and are freed once the GC finds no + `Bytes` reaching them, slices included. `Bytes(ByteArray)` holds Kotlin's for a record passed in. Strings are `String`, `Option` `T?`, a tuple `Pair`, `Triple`, or else a generated `Tuple`, though a tuple of one returned whole is its element, `Duration` `kotlin.time.Duration`, and an `Err` a thrown `Exception` @@ -480,8 +482,10 @@ then it breaks each runtime check, which must abort with its message. `tests/api runs the script through the C++ header at C++17, 20, and 23, with exceptions and RTTI off at 17 and 23, then checks its futures: a wake that lands during a poll, a cancel between a wake and its poll that loses nothing, and a coroutine resumed or destroyed. `tests/api.kt` -runs the script through the Kotlin on the JVM, checks its futures the same way, and breaks -each runtime check, which must throw or abort with its message. `just ffi android` runs +runs the script through the Kotlin on the JVM, checks its futures the same way, that a call +after `close()` throws, that `Bytes` and their slices keep Rust's buffer, past the object or +record they came from, until the GC frees it, and that calls racing `close()` or the GC +read live values, and breaks each runtime check, which must throw or abort with its message. `just ffi android` runs the same on ART: the Kotlin and `tests/api.kt`, shrunk by R8 into an app's dex with only the generated rules keeping what JNI looks up, against the library built by the NDK for the device's ABI, which ANDROID_HOME and ANDROID_NDK_HOME locate. Nightly runs it on an diff --git a/ffi/moq/bench/export.kt b/ffi/moq/bench/export.kt index 4c6d49391..e4241b116 100644 --- a/ffi/moq/bench/export.kt +++ b/ffi/moq/bench/export.kt @@ -54,6 +54,7 @@ fun round(): String { // Reads: each frame written, then read through a suspend fun whose future is ready at // its first poll, so it never suspends. var last: FrameFrame? = null + var large: FrameFrame? = null val read = runBlocking { val subscriber = track.consume().use { it.subscribe(null) } val start = System.nanoTime() @@ -69,24 +70,44 @@ fun round(): String { group.finish() } } - perFrame(start).also { subscriber.close() } + perFrame(start).also { + // A frame long enough that Rust lends its payload rather than copying it. + track.appendGroup().use { group -> + subscriber.recvGroup()!!.use { reader -> + group.writeFrame(at, ByteArray(4096)) + large = reader.readFrame()!! + } + group.finish() + } + subscriber.close() + } } - // Payload reads: the last frame's 100 bytes, lent without a copy, then freed. + // Payload reads: the last frame's 100 bytes, copied across, and one of them read. start = System.nanoTime() last!!.use { read -> repeat(GROUPS * FRAMES) { - read.payload.use { closed += it.buffer.get(0) } + closed += read.payload[0] + } + } + val payload = perFrame(start) + + // Lent payload reads: a 4 KiB frame's payload, which Rust lends until the GC frees it, + // copied out. + start = System.nanoTime() + large!!.use { read -> + repeat(GROUPS * FRAMES) { + closed += read.payload.toByteArray()[0] } } - val lent = perFrame(start) + val lentPayload = perFrame(start) at.close() track.close() broadcast.close() val bang = if (closed != 0) "!" else "" - return "export (Kotlin): %.0f ns per stamped 100 B frame, %.0f reusing the timestamp, %.1f ns per handle call, %.0f ns per frame written and read, %.0f ns per payload read%s" - .format(stamped, reused, call, read, lent, bang) + return "export (Kotlin): %.0f ns per stamped 100 B frame, %.0f reusing the timestamp, %.1f ns per handle call, %.0f ns per frame written and read, %.0f ns per payload read, %.0f ns per 4 KiB payload copied out%s" + .format(stamped, reused, call, read, payload, lentPayload, bang) } fun main() { diff --git a/ffi/moq/moq.kt b/ffi/moq/moq.kt index 90becf3fd..0f2276a37 100644 --- a/ffi/moq/moq.kt +++ b/ffi/moq/moq.kt @@ -84,7 +84,7 @@ fun main() { expect(q.size == 1, "a frame's arrival wakes the read") q.drain() val hello = frame!! - expect(hello.payload.use { it.toByteArray().decodeToString() } == "hello", "a frame has its payload") + expect(hello.payload.toByteArray().decodeToString() == "hello", "a frame has its payload") expect(hello.timestamp == null, "an untimed frame has no timestamp") // When it arrived: moq-clock's `Instant`, from moq-clock's own export. expect(now().checkedDurationSince(hello.arrived()) != null, "a frame arrived before now") @@ -106,7 +106,7 @@ fun main() { writer.writeFrame(null, world) writer.finish() runBlocking { - expect(first.readFrame()?.payload?.use { it.toByteArray().contentEquals(world) } == true, "the last frame reads") + expect(first.readFrame()?.payload?.toByteArray()?.contentEquals(world) == true, "the last frame reads") expect(first.readFrame() == null, "a finished group reads null") } @@ -179,7 +179,7 @@ fun main() { expect(audio.sampleRate == 48000u, "Opus decodes at 48 kHz") expect(audio.channelCount == 2u, "the head is stereo") expect(audio.codec == CatalogAudioCodec.Opus, "the codec is Opus") - expect(audio.description?.use { it.toByteArray().contentEquals(head) } == true, "the head is the description") + expect(audio.description?.toByteArray()?.contentEquals(head) == true, "the head is the description") // A truncated head is moq-mux's error. try { codecOpusAudioConfig(head.copyOf(8)) @@ -219,7 +219,7 @@ fun main() { Json.parse("""{"on": true}""").use { status.update(it) } runBlocking { val frame = status.consume().recvGroup()?.readFrame() - expect(frame?.payload?.use { it.toByteArray().decodeToString() } == """{"on":true}""", "a subscriber reads the latest value") + expect(frame?.payload?.toByteArray()?.decodeToString() == """{"on":true}""", "a subscriber reads the latest value") } catalog.mutate { lent -> val sections = lent.ext @@ -342,7 +342,7 @@ fun main() { val subscriber = remote.requestBroadcast("live", null).track("video").subscribe(null) val frame = subscriber.recvGroup()?.readFrame() - expect(frame?.payload?.use { it.toByteArray().decodeToString() } == "hello", "a session reads the frame the server serves") + expect(frame?.payload?.toByteArray()?.decodeToString() == "hello", "a session reads the frame the server serves") established.close_() client.close_() diff --git a/ffi/runtime/jni.rs b/ffi/runtime/jni.rs index 24aa8832f..856c1013a 100644 --- a/ffi/runtime/jni.rs +++ b/ffi/runtime/jni.rs @@ -5,8 +5,8 @@ //! or anything else encoded into a `byte[]`: little-endian, field by field, as the C //! struct holds it, with a buffer as its length (-1 for none) and its bytes, and an array //! as its length (-1 for none) and its elements. Bytes Rust returns cross copied when short, -//! and otherwise as their owner, which a Kotlin `Bytes` lends as a direct `ByteBuffer` -//! until it frees it. +//! and otherwise as their owner, which a Kotlin `Bytes` reads through a call each until the +//! GC collects it. use std::cell::Cell; use std::ffi::{CStr, c_void}; @@ -161,14 +161,14 @@ pub fn array(env: *mut JNIEnv, bytes: &[u8]) -> jbyteArray { } } -/// The longest bytes Rust returns that cross copied, freed at once: a lent `ByteBuffer` -/// costs `NewDirectByteBuffer`'s upcall and a cleaner registration, which copying up to -/// about this many bytes undercuts. +/// The longest bytes Rust returns that cross copied, freed at once: a lent buffer costs a +/// cleaner registration, and a call per read, which copying up to about this many bytes +/// undercuts. const COPIED: usize = 1024; /// A buffer Rust returned or lent, as Kotlin's `Native.owned` takes it: null for none, a /// `byte[]` copy of up to [`COPIED`] bytes, freeing the buffer, or else a `long[]` holding -/// the owner it lends them through. +/// the owner it lends them through and their length. /// /// # Safety /// `buf` is a buffer Rust returned or lent, freed by nothing else. @@ -184,19 +184,28 @@ pub unsafe fn hand(env: *mut JNIEnv, buf: rt::Buf) -> jobject { array }; } + let len = lent_len(&buf); // SAFETY: the caller's contract. - let owner = unsafe { self::owner(buf) }; + let lent = [unsafe { self::owner(buf) }, len.into()]; // SAFETY: `env` is this thread's. unsafe { - let array = ((**env).v1_2.NewLongArray)(env, 1); + let array = ((**env).v1_2.NewLongArray)(env, 2); if array.is_null() { fail(env, "jni", "a long[] is out of memory"); } - ((**env).v1_2.SetLongArrayRegion)(env, array, 0, 1, &owner); + ((**env).v1_2.SetLongArrayRegion)(env, array, 0, 2, lent.as_ptr()); array } } +/// The length of a buffer lent to Kotlin, whose `Bytes` counts in an `Int`. +fn lent_len(buf: &rt::Buf) -> i32 { + match i32::try_from(buf.len) { + Ok(len) => len, + Err(_) => rt::fail("jni", "a buffer is too long for Bytes"), + } +} + /// The owner of a buffer Rust returned, handed to Kotlin uncopied: a view, which Rust lent /// only for a call, is copied into an owned buffer. /// @@ -211,22 +220,42 @@ unsafe fn owner(buf: rt::Buf) -> jlong { buf.owner as usize as jlong } -/// A direct `ByteBuffer` over the bytes `owner` holds, which it lends until freed. +/// The bytes `owner` holds from `start`, `len` long, failing loud past their end, which +/// Kotlin checks first. /// /// # Safety /// `owner` came from [`hand`], and is not yet freed. -pub unsafe fn buffer(env: *mut JNIEnv, owner: jlong) -> jobject { - // SAFETY: the caller's contract. - let buf = unsafe { rt::Buf::of_owner(owner as usize as _) }; - let Ok(len) = jlong::try_from(buf.len) else { - rt::fail("jni", "a buffer is too long for a ByteBuffer") +unsafe fn range<'a>(owner: jlong, start: jint, len: jint) -> &'a [u8] { + // SAFETY: the caller's contract; the owner holds the bytes until freed. + let bytes = unsafe { + let buf = rt::Buf::of_owner(owner as usize as _); + std::slice::from_raw_parts(buf.ptr, buf.len) }; - // SAFETY: `env` is this thread's, and the bytes live until the owner is freed. - let buffer = unsafe { ((**env).v1_4.NewDirectByteBuffer)(env, buf.ptr.cast_mut().cast(), len) }; - if buffer.is_null() { - fail(env, "jni", "a ByteBuffer is out of memory"); + match (usize::try_from(start), usize::try_from(len)) { + (Ok(start), Ok(len)) if start <= bytes.len() && len <= bytes.len() - start => &bytes[start..start + len], + _ => rt::fail("jni", "a read is past a lent buffer's end"), + } +} + +/// The byte at `index` of those `owner` holds. +/// +/// # Safety +/// As [`range`]. +pub unsafe fn byte_at(owner: jlong, index: jint) -> jbyte { + // SAFETY: the caller's contract. + unsafe { range(owner, index, 1)[0] as jbyte } +} + +/// Copy the bytes `owner` holds from `start`, `len` long, into `array` at `at`. +/// +/// # Safety +/// As [`range`], and `array` is a live `byte[]`. +pub unsafe fn copy(env: *mut JNIEnv, owner: jlong, start: jint, len: jint, array: jbyteArray, at: jint) { + // SAFETY: the caller's contract; JNI throws if `array` is too short. + unsafe { + let bytes = range(owner, start, len); + ((**env).v1_2.SetByteArrayRegion)(env, array, at, len, bytes.as_ptr().cast()); } - buffer } /// Free the bytes `owner` holds. @@ -617,7 +646,7 @@ impl Wire for Owned { } // None as -1, bytes up to [`COPIED`] long as their length and a copy, and longer ones as - // [`LENT`] and their owner. + // [`LENT`], their owner, and their length. fn write(self, w: &mut Writer) { let buf = self.0; if buf.is_none() { @@ -630,9 +659,11 @@ impl Wire for Owned { buf.free(); } } else { + let len = lent_len(&buf); w.put(LENT); // SAFETY: a buffer Rust returned or lent, handed over here. w.put(unsafe { owner(buf) }); + w.put(len); } } } diff --git a/ffi/runtime/rt.kt b/ffi/runtime/rt.kt index eb69cf20e..111c496d2 100644 --- a/ffi/runtime/rt.kt +++ b/ffi/runtime/rt.kt @@ -63,11 +63,10 @@ class Writer { i32(-1) return } - v.read { src -> - room(4 + src.remaining()) - b.putInt(src.remaining()) - b.put(src) - } + room(4 + v.size) + b.putInt(v.size) + v.copyInto(b.array(), b.position()) + b.position(b.position() + v.size) } fun string(v: String?) = bytes(v?.encodeToByteArray()) @@ -163,7 +162,7 @@ class Reader(bytes: ByteArray) { /** Bytes Rust handed over, copied or lent by their owner, or null for none. */ fun owned(): Bytes? = when (val n = b.getInt()) { -1 -> null - LENT -> Bytes(b.getLong()) + LENT -> Bytes(Lent(b.getLong()), b.getInt()) else -> Bytes(ByteArray(n).also { b.get(it) }) } @@ -314,27 +313,37 @@ fun interface Drop { fun drop(raw: Long) } -/** Frees bytes Rust handed over, once, at [free] or once the GC collects [buffer]. */ -class Free(buffer: java.nio.ByteBuffer, private val owner: Long) : Cleanup(buffer) { +/** + * Rust's buffer, which [owner] holds, freed once the GC collects this. Each read passes it + * to the JNI function reading the buffer, as JNI keeps an argument reachable until the call + * returns: a fence on every JVM and Android level, where `Reference.reachabilityFence` + * needs API 28. + */ +class Lent(internal val owner: Long) { + init { + Free(this, owner) + } +} + +private class Free(lent: Lent, private val owner: Long) : Cleanup(lent) { init { track() } - fun free() { - if (untrack()) __bytes_free(owner) + override fun collected() { + untrack() + __bytes_free(owner) } - - override fun collected() = free() } -/** What precedes a lent buffer's owner where an encoded one's length would. */ +/** What precedes a lent buffer's owner and length where an encoded one's length would. */ const val LENT = -2 -/** Bytes a call returned: copied, lent by the owner a `LongArray` holds, or null for none. */ +/** Bytes a call returned: copied, lent by the owner and length a `LongArray` holds, or null for none. */ fun owned(given: Any?): Bytes? = when (given) { null -> null is ByteArray -> Bytes(given) - else -> Bytes((given as kotlin.LongArray)[0]) + else -> (given as kotlin.LongArray).let { Bytes(Lent(it[0]), it[1].toInt()) } } /** Run [block] with the raw handle of [handle], or 0 for none, held until it returns. */ diff --git a/ffi/src/kotlin.rs b/ffi/src/kotlin.rs index 4f024bfa2..716a376bd 100644 --- a/ffi/src/kotlin.rs +++ b/ffi/src/kotlin.rs @@ -13,9 +13,9 @@ //! [`runtime/jni.rs`](../runtime/jni.rs), the same on the Rust side. //! //! A value crosses JNI as a scalar, a handle as a `long`, bytes passed in as a `byte[]`, -//! bytes returned as their owner, which a `Bytes` lends as a direct `ByteBuffer`, or -//! encoded into a `byte[]` field by field as its C struct holds it, so each call is one -//! crossing. An object or `Bytes` never closed is freed once the GC collects it. +//! bytes returned as their owner, which a `Bytes` reads through a call each, or encoded +//! into a `byte[]` field by field as its C struct holds it, so each call is one crossing. +//! An object never closed, and Rust's bytes, are freed once the GC collects them. use std::collections::{BTreeMap, BTreeSet}; use std::fmt::Write; @@ -82,7 +82,7 @@ pub fn generate(abi: &Abi<'_>, packages: &str, contract: u64) -> Result, packages: &str, contract: u64) -> Result jobject {{\n\ - \tunsafe {{ rt::buffer(env, owner) }}\n\ + pub unsafe extern \"system\" fn Java_{pkg}_Native__1_1bytes_1get(_: *mut JNIEnv, _: jclass, _: jobject, owner: jlong, index: jint) -> jbyte {{\n\ + \tunsafe {{ rt::byte_at(owner, index) }}\n\ + }}\n\n\ + #[unsafe(no_mangle)]\n\ + pub unsafe extern \"system\" fn Java_{pkg}_Native__1_1bytes_1copy(env: *mut JNIEnv, _: jclass, _: jobject, owner: jlong, start: jint, len: jint, destination: jbyteArray, at: jint) {{\n\ + \tunsafe {{ rt::copy(env, owner, start, len, destination, at) }}\n\ }}\n\n\ #[unsafe(no_mangle)]\n\ pub unsafe extern \"system\" fn Java_{pkg}_Native__1_1bytes_1free(_: *mut JNIEnv, _: jclass, owner: jlong) {{\n\ @@ -146,41 +150,61 @@ const IMPORTS: &[&str] = &["kotlin.coroutines.resume", "kotlin.time.toDuration"] /// The bytes Rust returns, copied when short and otherwise lent. const BYTES: &str = "/** - * Bytes Rust returned: up to 1 KiB copied, and longer ones lent without a copy, which - * [buffer] reads until [close] frees them, after which reading it is undefined. Unclosed, - * lent ones are freed once the GC collects [buffer], so keep it, not only a slice or - * duplicate of it, while reading. + * Bytes Rust returned, or a record holds, read only through these methods: no view of + * Rust's memory escapes, so nothing reads it once freed. Up to 1 KiB from Rust are copied + * into Kotlin's memory. Longer ones stay in Rust's buffer, each read a crossing, and are + * freed once the GC finds no `Bytes` reaching it, its slices included. */ -class Bytes private constructor(buffer: java.nio.ByteBuffer, owner: Long) : AutoCloseable { -\t/** Bytes Kotlin holds, as a record Rust takes needs, which [close] leaves readable. */ -\tconstructor(bytes: ByteArray) : this(java.nio.ByteBuffer.wrap(bytes), 0L) - -\tinternal constructor(owner: Long) : this(Native.__bytes_buffer(owner), owner) - -\t/** The bytes, read-only. */ -\tval buffer: java.nio.ByteBuffer = buffer.asReadOnlyBuffer() - -\t// What the GC collects: a view of [buffer] need not keep it reachable, on the JVM or ART. -\tprivate val free = if (owner == 0L) null else Native.Free(this.buffer, owner) - -\tprivate var closed = false +class Bytes private constructor( +\t// Kotlin's bytes, or null for Rust's. +\tprivate val array: ByteArray?, +\t// Rust's buffer, which every slice of it holds. +\tprivate val lent: Native.Lent?, +\t// Where these start in [array] or [lent]. +\tprivate val offset: Int, +\t/** How many bytes these hold. */ +\tval size: Int, +) { +\t/** [bytes] held without a copy, as a record passed in takes them. */ +\tconstructor(bytes: ByteArray) : this(bytes, null, 0, bytes.size) + +\tinternal constructor(lent: Native.Lent, size: Int) : this(null, lent, 0, size) + +\t/** The byte at [index], throwing `IndexOutOfBoundsException` out of range. */ +\toperator fun get(index: Int): Byte { +\t\tif (index < 0 || index >= size) throw kotlin.IndexOutOfBoundsException(\"index $index of $size bytes\") +\t\tval array = array ?: return lent!!.let { Native.__bytes_get(it, it.owner, offset + index) } +\t\treturn array[offset + index] +\t} -\t/** The bytes, copied. */ -\tfun toByteArray(): ByteArray = read { b -> ByteArray(b.remaining()).also { b.get(it) } } +\t/** The bytes from [startIndex] until [endIndex], sharing these without a copy. */ +\tfun slice(startIndex: Int, endIndex: Int = size): Bytes { +\t\trange(startIndex, endIndex) +\t\treturn Bytes(array, lent, offset + startIndex, endIndex - startIndex) +\t} \t/** -\t * Run [block] with a view of the bytes from their start, which throws once closed. Locked, -\t * so this and [buffer] stay reachable, and [close] waits, until it returns. +\t * Copy the bytes from [startIndex] until [endIndex] into [destination] at +\t * [destinationOffset], returning it, as `ByteArray.copyInto` does. \t */ -\tinternal fun read(block: (java.nio.ByteBuffer) -> R): R = synchronized(this) { -\t\tkotlin.check(!closed) { \"Bytes is closed\" } -\t\tblock(buffer.duplicate().also { it.clear() }) +\tfun copyInto(destination: ByteArray, destinationOffset: Int = 0, startIndex: Int = 0, endIndex: Int = size): ByteArray { +\t\trange(startIndex, endIndex) +\t\tval n = endIndex - startIndex +\t\tif (destinationOffset < 0 || destinationOffset > destination.size - n) { +\t\t\tthrow kotlin.IndexOutOfBoundsException(\"$n bytes at $destinationOffset of ${destination.size}\") +\t\t} +\t\tval array = array +\t\tif (array != null) return array.copyInto(destination, destinationOffset, offset + startIndex, offset + endIndex) +\t\tif (n > 0) lent!!.let { Native.__bytes_copy(it, it.owner, offset + startIndex, n, destination, destinationOffset) } +\t\treturn destination \t} -\toverride fun close() { -\t\tsynchronized(this) { -\t\t\tclosed = true -\t\t\tfree?.free() +\t/** The bytes, copied. */ +\tfun toByteArray(): ByteArray = array?.copyOfRange(offset, offset + size) ?: copyInto(ByteArray(size)) + +\tprivate fun range(startIndex: Int, endIndex: Int) { +\t\tif (startIndex < 0 || startIndex > endIndex || endIndex > size) { +\t\t\tthrow kotlin.IndexOutOfBoundsException(\"$startIndex until $endIndex of $size bytes\") \t\t} \t} } diff --git a/ffi/tests/api.kt b/ffi/tests/api.kt index 3025bf566..266035208 100644 --- a/ffi/tests/api.kt +++ b/ffi/tests/api.kt @@ -110,8 +110,8 @@ fun script() { println(if (doubled == null) "doubled None" else "doubled Some((${doubled.first?.let { "Some($it)" } ?: "None"},))") } // A borrowed or boxed tuple of one is a tuple, not its element. - val lentOne = lentBack(Tuple1(byteArrayOf(1, 2))).first.use { it.toByteArray().toList() } - val boxedOne = boxedTuple(byteArrayOf(3)).first.use { it.toByteArray().toList() } + val lentOne = lentBack(Tuple1(byteArrayOf(1, 2))).first.toByteArray().toList() + val boxedOne = boxedTuple(byteArrayOf(3)).first.toByteArray().toList() println("lent back $lentOne boxed $boxedOne") // A borrowed string and an owned one share a tuple struct. println("title Some((\"${title(true)!!.first}\",)) ${if (title(false) == null) "None" else "Some"} named Some((\"${named("x")!!.first}\",))") @@ -237,7 +237,7 @@ fun script() { runBlocking { channel.send(byteArrayOf()) try { - println("nonempty ${channel.recvNonempty().use { it.buffer.remaining() }}") + println("nonempty ${channel.recvNonempty().size}") } catch (e: ErrorException) { println("nonempty err ${e.message}") } @@ -348,7 +348,7 @@ fun script() { println("words ${words.size} ${words.joinToString(", ", "[", "]") { "\"$it\"" }}") println("sentence ${sentence(words)} ${sentence(null)}") println("sentence ${sentence(emptyList())}|") - println("chunks ${chunks("abcde".encodeToByteArray(), 2uL).map { it.use { b -> b.toByteArray().toList() } }}") + println("chunks ${chunks("abcde".encodeToByteArray(), 2uL).map { it.toByteArray().toList() }}") val counters = counters(listOf("x", "y")) println("counters ${counters.joinToString(", ") { "${it.name()} ${it.get()}" }}") // Each counter moves in, so Kotlin closes it. @@ -378,7 +378,7 @@ fun script() { Trail.new(listOf(Point(1, 2), Point(3, 4)), listOf("a", "b")).use { trail -> println("trail ${trail.points().joinToString(", ", "[", "]") { it.debug() }} ${trail.labels().joinToString(", ", "[", "]") { it.debug() }}") // Returned iterators, each collected at the call: drained labels are gone after. - println("iterated ${trail.iter().joinToString(", ", "[", "]") { it.debug() }} ${trail.names().joinToString(", ", "[", "]") { "\"$it\"" }} ${trail.bytes().use { it.toByteArray() }.joinToString(", ", "[", "]") { it.toUByte().toString() }}") + println("iterated ${trail.iter().joinToString(", ", "[", "]") { it.debug() }} ${trail.names().joinToString(", ", "[", "]") { "\"$it\"" }} ${trail.bytes().toByteArray().joinToString(", ", "[", "]") { it.toUByte().toString() }}") println("drained ${trail.drain().joinToString(", ", "[", "]") { it.debug() }} then ${trail.labels().joinToString(", ", "[", "]") { it.debug() }}") } val numbered = numbered(listOf("p", "q")) @@ -402,8 +402,8 @@ fun escaped(path: String): String = path.encodeToByteArray().joinToString("") { } } -/** The bytes as text, closing them. */ -fun Bytes.text(): String = use { it.toByteArray().decodeToString() } +/** The bytes as text. */ +fun Bytes.text(): String = toByteArray().decodeToString() fun check(ok: Boolean, what: String) { if (!ok) { @@ -550,43 +550,80 @@ fun broken(which: String) { } } -// What frees an object or bytes nobody closed: the GC, once each is collected, and only -// what closing did not free already. Rust's bytes up to 1 KiB cross copied, freed at the -// call, and longer ones lent until closed or collected. +// What frees what: an object's close, or the GC once it is collected if never closed, and +// Rust's bytes only the GC, once no Bytes reaching them is reachable. Rust's bytes up to +// 1 KiB cross copied, freed at the call, and longer ones lent. fun owned() { for (n in 1023..1025) { val payload = ByteArray(n) { it.toByte() } val packet = nextPacket(Packet(1u, Bytes(payload))) - check(packet.seq == 2u && packet.payload.use { it.toByteArray() }.contentEquals(payload), "a record's $n bytes cross both ways") + check(packet.seq == 2u && packet.payload.toByteArray().contentEquals(payload), "a record's $n bytes cross both ways") + val sliced = nextPacket(Packet(1u, packet.payload.slice(1, n - 1))).payload + check(sliced.toByteArray().contentEquals(payload.copyOfRange(1, n - 1)), "a slice of a record's $n bytes crosses as its range") } var freed = dropped() - for (n in 1023u..1025u) { - val bytes = tokenBytes(n, n) - if (n <= 1024u) freed++ + for (n in 1023..1025) { + val bytes = tokenBytes(n.toUInt(), n.toUInt()) + if (n <= 1024) freed++ check(dropped() == freed, "$n bytes are freed at the call only if copied") - check(bytes.toByteArray().contentEquals(ByteArray(n.toInt()) { n.toByte() }), "$n bytes read back") - bytes.close() - if (n > 1024u) freed++ - check(dropped() == freed, "closing $n bytes frees them") - bytes.close() - check(dropped() == freed, "closing $n bytes again frees nothing") - check(runCatching { bytes.toByteArray() }.isFailure, "closed $n bytes refuse a read") + reads(bytes, n.toByte(), "$n bytes") } + collected(freed + 1u, "the GC frees lent bytes nothing reaches") + reads(Bytes(ByteArray(1025) { 9 }), 9, "Kotlin's bytes") + // A call after close throws, and close frees at once, once. val before = dropped() - Token.new(1u).close() - tokenBytes(2u, 1025u).close() - check(dropped() == before + 2u, "closing frees at once") + val token = Token.new(1u) + token.close() + check(dropped() == before + 1u, "closing frees at once") + token.close() + check(dropped() == before + 1u, "closing again frees nothing") + check(runCatching { token.id }.exceptionOrNull()?.message == "Token is closed", "a call after close throws") + + // Bytes outlive the object that returned them and the record that held them. + val body = ByteArray(2048) { 'b'.code.toByte() } + val message = Message.new(Stamp.fromMillis(1u), body) + val payload = message.payload + message.close() + val carried = nextPacket(Packet(1u, Bytes(body))).payload + uncollected(before + 1u, "the GC frees nothing still reachable") + check(payload.toByteArray().contentEquals(body), "bytes outlive the object that returned them") + check(carried.toByteArray().contentEquals(body), "bytes outlive the record that held them") + + // Never closed, each is freed once collected, but a slice keeps its bytes. unclosed() - val deadline = System.nanoTime() + 10.seconds.inWholeNanoseconds - while (dropped() < before + 4u) { - check(System.nanoTime() < deadline, "the GC frees what nobody closed") - // Not System.gc(), which ART skips unless finalization ran since its last call. - Runtime.getRuntime().gc() - Thread.sleep(1) + collected(before + 3u, "the GC frees what nobody closed") + keptBySlice(before + 3u) + collected(before + 4u, "the GC frees bytes once no slice reaches them") + + raced() +} + +// Every read of [bytes], each of which is [value]. +fun reads(bytes: Bytes, value: Byte, what: String) { + val n = bytes.size + val all = bytes.toByteArray() + check(all.size == n && all.all { it == value }, "$what read back") + check(bytes[0] == value && bytes[n - 1] == value, "$what read by index") + val into = bytes.copyInto(ByteArray(n + 1) { 7 }, 1, 1, n) + check(into[0] == 7.toByte() && (1 until n).all { into[it] == value } && into[n] == 7.toByte(), "$what copied into a range") + val slice = bytes.slice(2, n - 1).slice(1) + check(slice.size == n - 4 && slice.toByteArray().all { it == value } && slice[n - 5] == value, "$what sliced twice") + check(bytes.slice(n).size == 0 && bytes.slice(0, 0).toByteArray().isEmpty(), "$what sliced empty") + val outOfRange = listOf<() -> Any>( + { bytes[n] }, + { bytes[-1] }, + { slice[slice.size] }, + { bytes.slice(2, 1) }, + { bytes.slice(0, n + 1) }, + { slice.slice(0, slice.size + 1) }, + { bytes.copyInto(ByteArray(n - 1)) }, + { bytes.copyInto(ByteArray(n), 1) }, + { bytes.copyInto(ByteArray(n), -1, 0, 1) }, + ) + for (read in outOfRange) { + check(runCatching(read).exceptionOrNull() is IndexOutOfBoundsException, "$what refuse a read out of range") } - // The closed ones were collected by the same collections, and not freed again. - check(dropped() == before + 4u, "the GC frees each once") } // In its own frame, so nothing on the caller's keeps them reachable. The bytes are long @@ -596,6 +633,90 @@ fun unclosed() { tokenBytes(4u, 1025u) } +// A slice of Rust's bytes, made in its own frame so nothing keeps the Bytes it came from, +// which keeps Rust's buffer while it reads. +fun keptBySlice(freed: UInt) { + val view = sliced(5u) + uncollected(freed, "a slice keeps Rust's bytes") + check(view.size == 10 && view.toByteArray().all { it == 5.toByte() }, "a slice reads Rust's bytes once the Bytes it came from are gone") +} + +fun sliced(id: UInt): Bytes = tokenBytes(id, 1025u).slice(1015) + +// Frees racing calls: a close racing calls on other threads, and the GC freeing what a +// call reads while it runs. +fun raced() { + // Each call either runs, holding the handle so Rust's value outlives it, or throws. + var before = dropped() + val token = Token.new(5u) + val ran = java.util.concurrent.atomic.AtomicInteger() + val failed = java.util.concurrent.atomic.AtomicInteger() + val callers = List(8) { + thread { + while (true) { + val id = try { + token.id + } catch (e: IllegalStateException) { + check(e.message == "Token is closed", "a call racing close throws") + failed.incrementAndGet() + return@thread + } + check(id == 5u, "a call racing close reads the live value") + ran.incrementAndGet() + } + } + } + while (ran.get() < 100) Thread.yield() + token.close() + callers.forEach { it.join() } + check(failed.get() == 8 && dropped() == before + 1u, "close racing calls drops Rust's value once") + + // Calls on values nothing else reaches, while another thread collects: each reads what + // it was given, never a buffer freed under it and reused by the next, which holds + // another id. + before = dropped() + val collecting = java.util.concurrent.atomic.AtomicBoolean(true) + val cycles = java.util.concurrent.atomic.AtomicInteger() + val collector = thread { + while (collecting.get()) { + Runtime.getRuntime().gc() + cycles.incrementAndGet() + } + } + var rounds = 0u + while (cycles.get() < 50) { + val id = rounds % 100u + 1u + check(tokenBytes(id, 2048u)[2047] == id.toByte(), "a read by index races the GC") + check(tokenBytes(id, 2048u).slice(1024).toByteArray().all { it == id.toByte() }, "a slice's copy races the GC") + check(Token.new(id).id == id, "a call on an object races the GC") + rounds++ + } + collecting.set(false) + collector.join() + collected(before + 3u * rounds, "the GC frees what each call raced") +} + +// Collect until Rust's values dropped count [n], and none past it. +fun collected(n: UInt, what: String) { + val deadline = System.nanoTime() + 10.seconds.inWholeNanoseconds + while (dropped() < n) { + check(System.nanoTime() < deadline, what) + // Not System.gc(), which ART skips unless finalization ran since its last call. + Runtime.getRuntime().gc() + Thread.sleep(1) + } + uncollected(n, "$what, each once") +} + +// Collect a few times, checking that Rust's values dropped still count [n]. +fun uncollected(n: UInt, what: String) { + repeat(5) { + Runtime.getRuntime().gc() + Thread.sleep(1) + } + check(dropped() == n, what) +} + fun main(args: Array) { when { args.isEmpty() -> script() diff --git a/quest/m1/ffi-export/lifetimes.md b/quest/m1/ffi-export/lifetimes.md index 1dfcbcce9..042394a73 100644 --- a/quest/m1/ffi-export/lifetimes.md +++ b/quest/m1/ffi-export/lifetimes.md @@ -22,11 +22,6 @@ an error, never a crash or undefined behavior. - **Python**: drop `close()` and `with`; CPython's refcount frees in `tp_dealloc`. Its bytes, a `memoryview` over a refcounted owner, are already safe. Lands after [#407](https://github.com/moq-dev/rust2/pull/407). -- **Kotlin**: objects keep `close()`, which already throws `IllegalStateException` after, and - a racing close waits for the call. Its bytes over 1 KiB, a direct `ByteBuffer` that - `Bytes.close()` or the GC can free under a live slice, become owned by the GC as Go's are: - no `close()`, freed by a cleaner on the buffer that its slices and views keep alive. Verify - that on ART at API 24 and 35, or else read them through methods with no buffer escaping. - **Go**: settled in [#411](https://github.com/moq-dev/rust2/pull/411): objects keep `Close`, with every later call returning an error, and bytes are a GC-owned `Bytes` with no raw slice, read through methods and copied by `Copy()`. @@ -39,5 +34,5 @@ an error, never a crash or undefined behavior. contiguous bytes. A dangling reference stays C++'s own undefined behavior. - **C**: out of scope; its `_free` follows C's norms. -Public API: breaking for Swift, Python, Kotlin bytes, and C++ ownership; each gets a -`MIGRATE.md` entry. +Public API: breaking for Swift, Python, and C++ ownership; each gets a `MIGRATE.md` +entry. From dddb35683fb991cb116c28833106af0d1d2de625 Mon Sep 17 00:00:00 2001 From: Luke Curley <432854+kixelated@users.noreply.github.com> Date: Mon, 2 Nov 2026 09:58:20 -0800 Subject: [PATCH 2/3] chore(quest): claim quest/m3/ffi-kotlin-bytes-equality.md Quest-Claim: 20c6c7e60ea8336a From bd4c818f347e52617ac61ec5fec6d5f792f1e9c3 Mon Sep 17 00:00:00 2001 From: Luke Curley <432854+kixelated@users.noreply.github.com> Date: Mon, 2 Nov 2026 10:07:31 -0800 Subject: [PATCH 3/3] feat(ffi): compare and hash Kotlin's Bytes by content Co-Authored-By: Claude Opus 5.5 --- ffi/MIGRATE.md | 8 ++++--- ffi/README.md | 3 ++- ffi/src/kotlin.rs | 11 ++++++++++ ffi/tests/api.kt | 31 +++++++++++++++++++++++++++ quest/m3/README.md | 1 - quest/m3/ffi-kotlin-bytes-equality.md | 21 ------------------ 6 files changed, 49 insertions(+), 26 deletions(-) delete mode 100644 quest/m3/ffi-kotlin-bytes-equality.md diff --git a/ffi/MIGRATE.md b/ffi/MIGRATE.md index 574c33994..8146fda9b 100644 --- a/ffi/MIGRATE.md +++ b/ffi/MIGRATE.md @@ -41,9 +41,11 @@ contents through these instead, such as `bytes.toByteArray()` or `bytes[0]`. Rust's buffer is freed once the GC finds no `Bytes` reaching it, its slices included. A record's bytes field is a `Bytes` too: build one to pass in with `Bytes(byteArray)`. - Arguments stay `ByteArray`. Kotlin's objects never closed are freed once the GC - collects them, and an exported type named `bytes` clashes with the runtime's - `_bytes`, which takes a `rename`. + A `Bytes` equals and hashes by its contents, where a `ByteArray` did by identity, so a + record holding bytes equals one holding the same, and keys a map by them; compare with + `===` for identity. Arguments stay `ByteArray`. Kotlin's objects never closed are freed + once the GC collects them, and an exported type named `bytes` clashes with the + runtime's `_bytes`, which takes a `rename`. - A type parameter bounded by serde's `Serialize` or `Deserialize` takes `serde_json::Value`, not `String`: C passes a `_json` it parses from JSON text with `_json_parse`, not a buffer, and a generic type at such a parameter is diff --git a/ffi/README.md b/ffi/README.md index 7ac2d9a39..c8b824af8 100644 --- a/ffi/README.md +++ b/ffi/README.md @@ -277,7 +277,8 @@ in as a `ByteArray`, bytes returned as their owner, and anything else encoded in longer ones stay in Rust's buffer, each read a JNI call that passes the buffer's owner, which JNI keeps reachable until the call returns, and are freed once the GC finds no `Bytes` reaching them, slices included. `Bytes(ByteArray)` holds Kotlin's for a record - passed in. + passed in. A `Bytes` equals and hashes by its contents, wherever each keeps them, so a + record holding bytes does too; Rust's lent ones are copied across in one call to compare. Strings are `String`, `Option` `T?`, a tuple `Pair`, `Triple`, or else a generated `Tuple`, though a tuple of one returned whole is its element, `Duration` `kotlin.time.Duration`, and an `Err` a thrown `Exception` holding it, whose message is its `Display`. diff --git a/ffi/src/kotlin.rs b/ffi/src/kotlin.rs index 8965d009d..1175e7e82 100644 --- a/ffi/src/kotlin.rs +++ b/ffi/src/kotlin.rs @@ -202,6 +202,17 @@ class Bytes private constructor( \t/** The bytes, copied. */ \tfun toByteArray(): ByteArray = array?.copyOfRange(offset, offset + size) ?: copyInto(ByteArray(size)) +\t/** Whether [other] holds the same bytes, wherever each keeps them. */ +\toverride fun equals(other: Any?): Boolean = +\t\tthis === other || other is Bytes && size == other.size && contents() == other.contents() + +\t/** A hash of the bytes, as [equals] compares them. */ +\toverride fun hashCode(): Int = contents().hashCode() + +\t// The bytes in Kotlin's memory, Rust's copied across in one call. +\tprivate fun contents(): java.nio.ByteBuffer = +\t\tjava.nio.ByteBuffer.wrap(array ?: toByteArray(), if (array == null) 0 else offset, size) + \tprivate fun range(startIndex: Int, endIndex: Int) { \t\tif (startIndex < 0 || startIndex > endIndex || endIndex > size) { \t\t\tthrow kotlin.IndexOutOfBoundsException(\"$startIndex until $endIndex of $size bytes\") diff --git a/ffi/tests/api.kt b/ffi/tests/api.kt index 20cb0598d..a1a5e86f5 100644 --- a/ffi/tests/api.kt +++ b/ffi/tests/api.kt @@ -600,6 +600,7 @@ fun owned() { collected(before + 4u, "the GC frees bytes once no slice reaches them") raced() + compared() } // Every read of [bytes], each of which is [value]. @@ -699,6 +700,36 @@ fun raced() { collected(before + 3u * rounds, "the GC frees what each call raced") } +// Bytes compare and hash by their contents wherever each keeps them, Kotlin's or Rust's, +// copied or lent, whole or sliced, and so do records holding them, as map keys too. +fun compared() { + val body = ByteArray(2050) { (it * 7).toByte() } + // Rust's whole are lent, its 1000 copied; the middle two differ by a shift. + val ranges = listOf(0 to 2050, 1 to 1001, 2 to 1002, 5 to 5) + val forms = ranges.map { (from, until) -> + listOf( + Bytes(body.copyOfRange(from, until)), + Bytes(body).slice(from, until), + nextPacket(Packet(1u, Bytes(body.copyOfRange(from, until)))).payload, + nextPacket(Packet(1u, Bytes(body))).payload.slice(from, until), + ) + } + val keys = HashMap() + forms.forEachIndexed { i, same -> keys[Packet(1u, same[0])] = i } + val all = forms.flatMapIndexed { i, same -> same.map { i to it } } + for ((i, a) in all) { + for ((j, b) in all) { + check((a == b) == (i == j) && (Packet(1u, a) == Packet(1u, b)) == (i == j), "bytes ${ranges[i]} and ${ranges[j]} compare by content") + if (i == j) check(a.hashCode() == b.hashCode() && Packet(1u, a).hashCode() == Packet(1u, b).hashCode(), "bytes ${ranges[i]} hash by content") + } + check(keys[Packet(1u, a)] == i, "a record holding bytes ${ranges[i]} finds its key") + } + check(all.map { it.second }.toSet().size == ranges.size, "a set holds bytes once per content") + val last = Bytes(body.copyOf().also { it[body.size - 1]++ }) + check(last != forms[0][3] && forms[0][3] != last, "bytes differing in their last byte differ") + check(!forms[0][3].equals(body), "bytes differ from a ByteArray") +} + // Collect until Rust's values dropped count [n], and none past it. fun collected(n: UInt, what: String) { val deadline = System.nanoTime() + 10.seconds.inWholeNanoseconds diff --git a/quest/m3/README.md b/quest/m3/README.md index 1f39cf3d6..48eea3a73 100644 --- a/quest/m3/README.md +++ b/quest/m3/README.md @@ -39,7 +39,6 @@ generic std gap is planned here even when moq never calls it. - [Python's wake on the loop's own thread](/quest/m3/ffi-python-wake.md) - a wake on the event loop's thread resumes the future with no socket pair - [Python's call cost](/quest/m3/ffi-python-calls.md) - a call on an object costs what BoltFFI's does, or the gap is explained - [No unused import in an export's generated crate](/quest/m3/ffi-unused-c-void.md) - the crate rust2-ffi generates builds without warnings when no export uses `void *` -- [Kotlin: bytes compare by content](/quest/m3/ffi-kotlin-bytes-equality.md) - `Bytes` and records holding it compare and hash by their bytes - [C++: a move out of a shared copy while its future pends](/quest/m3/ffi-cpp-shared-future.md) - the last shared copy moves out by a clone even while a destroyed copy's future pends - [A box's clone_from, and a vector's AsRef and AsMut](/quest/m3/ts-box-vec-conversions.md) - `clone_from` of a `Box` and `AsRef<[T]>` of a `Vec` translate - [A std map with a crate's own BuildHasher](/quest/m3/ts-map-own-hasher.md) - std's and hashbrown's maps built with a crate's own hasher translate for keys that are JS objects diff --git a/quest/m3/ffi-kotlin-bytes-equality.md b/quest/m3/ffi-kotlin-bytes-equality.md deleted file mode 100644 index 1c0be2d3d..000000000 --- a/quest/m3/ffi-kotlin-bytes-equality.md +++ /dev/null @@ -1,21 +0,0 @@ -# [S] Kotlin: bytes compare by content - -## Goal - -Kotlin's `Bytes` compares and hashes by its bytes, as Rust's `Bytes` and `Vec` do, so -a generated data class holding bytes, such as a record with a payload, equals another -record with the same fields, and works as a map key. - -## Plan - -Found by [#432](https://github.com/moq-dev/rust2/pull/432): `Bytes` has no `equals` or -`hashCode`, so it compares by identity and a record holding bytes equals only itself. -This predates #432. - -Content equality must read lent bytes through the same JNI calls that keep their buffer -alive during a read, and cost no more crossings than the comparison needs: compare sizes -first, and copy or compare in bulk rather than per byte. Cache the hash only if a -measurement shows it matters. Prove it with a test comparing copied, lent, sliced, and -Kotlin-held bytes, and records holding each. - -Public API: breaking for code that relied on identity; it gets a `MIGRATE.md` entry.