From 3108b4bee076d20352ff4ad18f7caa828cc5b60a Mon Sep 17 00:00:00 2001 From: Elie Gambache Date: Mon, 5 Oct 2026 21:09:38 +0300 Subject: [PATCH 1/2] test(systeminfo): accept PNG screen captures on macOS The macOS implementation encodes the capture as PNG while Linux and Windows produce BMP; the test only accepted the BMP header. --- .../kotlin/com/example/systeminfo/SystemDesktopTest.kt | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/examples/systeminfo/src/jvmTest/kotlin/com/example/systeminfo/SystemDesktopTest.kt b/examples/systeminfo/src/jvmTest/kotlin/com/example/systeminfo/SystemDesktopTest.kt index 14f9242a..fb84ffc3 100644 --- a/examples/systeminfo/src/jvmTest/kotlin/com/example/systeminfo/SystemDesktopTest.kt +++ b/examples/systeminfo/src/jvmTest/kotlin/com/example/systeminfo/SystemDesktopTest.kt @@ -55,13 +55,16 @@ class SystemDesktopTest { } @Test - fun `capture screen is not empty and starts with BM`() { + fun `capture screen is a non-empty BMP or PNG image`() { SystemDesktop().use { desktop -> kotlinx.coroutines.runBlocking { val screen = desktop.captureScreen() assertTrue(screen.isNotEmpty()) - assertTrue(screen[0] == 'B'.toByte()) - assertTrue(screen[1] == 'M'.toByte()) + // Linux/Windows encode BMP ("BM"), macOS encodes PNG (0x89 "PNG") + val isBmp = screen[0] == 'B'.code.toByte() && screen[1] == 'M'.code.toByte() + val isPng = screen.size >= 4 && screen[0] == 0x89.toByte() && + screen[1] == 'P'.code.toByte() && screen[2] == 'N'.code.toByte() && screen[3] == 'G'.code.toByte() + assertTrue(isBmp || isPng, "unexpected image header: ${screen.take(4)}") } } } From a4b46665894cfc896d83ced2e6613b9e80125a18 Mon Sep 17 00:00:00 2001 From: Elie Gambache Date: Mon, 5 Oct 2026 21:09:38 +0300 Subject: [PATCH 2/2] fix: harden the generated FFM runtime and copy value types verbatim Value types - Enums and data classes are emitted on the JVM as verbatim copies of the native declarations (defaults, body members, companions, KDoc), with native-only imports dropped. Runtime fixes found by torture and seeded monkey tests - Exceptions thrown by JVM callbacks no longer kill the VM: they unwind the native caller and are rethrown with their original type. - Native error state is thread-local (errors leaked across threads). - Collections, strings, byte arrays and data class string fields of any size: oversized results are parked natively and read back instead of overflowing fixed buffers or calling the function twice. - Flows use credit-based backpressure (slow collectors lost elements). - Suspend calls always signal completion (ATOMIC start); job handles are owned by the JVM to avoid use-after-free on cancellation. - Object lifecycle uses a lease: calls after close() throw, close() is safe concurrently and from callbacks, arguments stay alive during calls, and the Cleaner no longer captures the proxy (unclosed objects leaked). - Coroutine completion uses static upcall stubs dispatched by call id and callback stubs are released per call (unbounded memory growth). - List/List and non-String map keys are encoded consistently. - Strings containing NUL are rejected instead of silently truncated. Callbacks are now valid for the duration of the call only; event streams should be exposed as Flow. --- README.md | 28 +- .../example/calculator/BufferTortureTest.kt | 114 +++ .../com/example/calculator/MonkeyTest.kt | 238 +++++ .../example/calculator/RuntimeTortureTest.kt | 283 ++++++ .../com/example/calculator/TortureSupport.kt | 43 + .../example/calculator/ValueTypeParityTest.kt | 78 ++ .../calculator/ValueTypeTortureTest.kt | 280 ++++++ .../com/example/calculator/Calculator.kt | 105 +++ .../com/example/calculator/ValueTypes.kt | 33 + .../nna/plugin/analysis/PsiSourceParser.kt | 22 +- .../nna/plugin/codegen/FfmProxyGenerator.kt | 891 ++++++++++++------ .../plugin/codegen/NativeBridgeGenerator.kt | 524 +++++----- .../nucleusframework/nna/plugin/ir/KneIR.kt | 11 + .../FfmProxyGeneratorValueTypeSourceTest.kt | 136 +++ 14 files changed, 2225 insertions(+), 561 deletions(-) create mode 100644 examples/calculator/src/jvmTest/kotlin/com/example/calculator/BufferTortureTest.kt create mode 100644 examples/calculator/src/jvmTest/kotlin/com/example/calculator/MonkeyTest.kt create mode 100644 examples/calculator/src/jvmTest/kotlin/com/example/calculator/RuntimeTortureTest.kt create mode 100644 examples/calculator/src/jvmTest/kotlin/com/example/calculator/TortureSupport.kt create mode 100644 examples/calculator/src/jvmTest/kotlin/com/example/calculator/ValueTypeParityTest.kt create mode 100644 examples/calculator/src/jvmTest/kotlin/com/example/calculator/ValueTypeTortureTest.kt create mode 100644 examples/calculator/src/nativeMain/kotlin/com/example/calculator/ValueTypes.kt create mode 100644 plugin-build/plugin/src/test/kotlin/dev/nucleusframework/nna/plugin/codegen/FfmProxyGeneratorValueTypeSourceTest.kt diff --git a/README.md b/README.md index f6f8007d..35d0d9a5 100644 --- a/README.md +++ b/README.md @@ -150,8 +150,8 @@ Every test compiles Kotlin/Native → `libcalculator.so` (470+ exported symbols) | `Set?` | — | ✅ 5t | — | — | — | -1 count = null sentinel | | `Map` | ✅ 12t | ✅ 12t | — | ✅ 2t | ✅ 2t | String→Int, Int→String, Int→Int, String→String + merge/empty | | `Map?` | — | ✅ 4t | — | — | — | -1 count = null sentinel | -| `(T) -> R` (lambda) | ✅ 15t | — | — | — | — | persistent `Arena.ofShared()` | -| `Flow` | — | ✅ 50t+ | — | — | — | `channelFlow` + 3 callbacks (onNext, onError, onComplete), incl. `Flow` | +| `(T) -> R` (lambda) | ✅ 15t | — | — | — | — | per-call upcall arena, exceptions propagate with their original type | +| `Flow` | — | ✅ 50t+ | — | — | — | static onNext/onError/onComplete stubs + credit-based backpressure, incl. `Flow` | ### Declarations @@ -174,9 +174,9 @@ Every test compiles Kotlin/Native → `libcalculator.so` (470+ exported symbols) | Data classes (nativeMain) | ✅ | auto-generates JVM data class + field marshalling | | Data classes (commonMain) | ✅ | reuses existing JVM type, no proxy generated | | Suspend functions | ✅ | `suspendCancellableCoroutine` + bidirectional cancellation (110+ tests) | -| Flow<T> return | ✅ | `channelFlow` + onNext/onError/onComplete callbacks (50+ tests) | -| Exception propagation | ✅ | `try/catch` wrapping, `KotlinNativeException` on JVM | -| Object lifecycle | ✅ | `Cleaner` for GC + `close()` for explicit release | +| Flow<T> return | ✅ | onNext/onError/onComplete callbacks with backpressure, no element dropped (50+ tests) | +| Exception propagation | ✅ | `try/catch` wrapping, `KotlinNativeException` on JVM (thread-local, safe under concurrency) | +| Object lifecycle | ✅ | `Cleaner` for GC + `close()` for explicit release; calls after `close()` throw `IllegalStateException` | ### Suspend functions @@ -193,7 +193,7 @@ suspend fun fetchData(query: String): String { val result = calc.fetchData("test") // suspends the coroutine ``` -**How it works**: the native bridge launches a `CoroutineScope` with a `Job`, passes continuation + exception callbacks as FFM upcall stubs. The JVM proxy uses `suspendCancellableCoroutine` to suspend until the native coroutine completes. +**How it works**: the native bridge launches a `CoroutineScope` with a `Job` and reports completion through two static FFM upcall stubs, dispatched by a per-call id. The JVM proxy uses `suspendCancellableCoroutine` to suspend until the native coroutine completes. **Cancellation**: JVM coroutine cancel → `Job.cancel()` on native side. Native `CancellationException` → JVM `CancellationException`. Bidirectional, automatic. @@ -215,7 +215,7 @@ calc.countUp(100).toList() // [1, 2, ..., 100] calc.infiniteFlow().take(3).toList() // [0, 1, 2] — auto-cancelled ``` -**How it works**: 3 native callbacks (`onNext`, `onError`, `onComplete`) are passed as FFM upcall stubs. The native side collects the Flow in a `CoroutineScope` and calls `onNext` for each element. The JVM proxy uses `channelFlow { trySend(...); awaitClose { cancelJob() } }`. +**How it works**: 3 static FFM upcall stubs (`onNext`, `onError`, `onComplete`) are dispatched by a per-call id. The native side collects the Flow in a `CoroutineScope` and only emits against credits granted as the JVM collector consumes elements, so a slow collector applies backpressure instead of losing elements. **Cancellation**: collecting only N elements (via `take`, `first`) automatically cancels the native Flow collection. Manual `Job.cancel()` also propagates. @@ -241,7 +241,9 @@ desktop.memoryFlow(2000L).collect { info -> JVM lambdas cross the FFM boundary via upcall stubs. The plugin generates all the FFM infrastructure automatically. -**Lifecycle**: each proxy object holds a persistent `Arena.ofShared()`. Upcall stubs live as long as the object — async callbacks (event handlers, listeners) work out of the box. The arena is freed on `close()` or GC. +**Lifecycle**: upcall stubs are allocated in a per-call arena and released when the call returns, so a callback is valid **for the duration of the call only** (as with JNI). Native code must not store a callback and invoke it later; expose event streams as `Flow` instead. + +**Exceptions**: an exception thrown by a JVM callback unwinds the native caller and is rethrown on the JVM with its original type and message. **Supported callback signatures**: - Params: `Int`, `Long`, `Double`, `Float`, `Boolean`, `Byte`, `Short`, `String`, `enum class`, `data class` @@ -260,14 +262,16 @@ calc.onValueChanged { value -> println("Value: $value") } calc.transform { it * 2 } calc.formatWith { "Result: $it" } -// Async callbacks work (e.g. native event listeners) -desktop.setTrayClickCallback { index -> println("Clicked: $index") } +// Event streams: use Flow rather than stored callbacks +desktop.trayClicks().collect { index -> println("Clicked: $index") } ``` ### Collections `List`, `Set`, and `Map` cross the FFM boundary using flat arrays (pointer + size), inspired by swift-java's `[UInt8]` lowering. +There is no size limit: results that do not fit the initial transfer buffers (4096 elements / 8 KB of packed strings) are parked natively and read back with a larger buffer, without calling the function twice. The same applies to large `String`, `ByteArray` and data class `String` fields. + **Supported element types**: `Int`, `Long`, `Double`, `Float`, `Short`, `Byte`, `Boolean`, `String`, `enum class` ```kotlin @@ -294,6 +298,8 @@ val meta = calc.getMetadata() // {current=42, scale=3} Data classes are marshalled **by value** (field decomposition) — each field becomes a separate C ABI argument. Supported field types: all primitives + `String`. +The JVM data class (and `enum class`) is a verbatim copy of the native declaration: default values, body members, companion objects, KDoc and annotations are preserved. Imports of native-only packages (`kotlinx.cinterop`, `platform.*`, `kotlin.native`) are dropped, so bodies must only use APIs available on the JVM. + ```kotlin // Can be in commonMain or nativeMain data class Point(val x: Int, val y: Int) @@ -407,6 +413,8 @@ Measured on Intel Core i5-14600 (20 cores), 45 GB RAM, Ubuntu 25.10, JDK 25 (Gra |---------|--------|-------------| | Private/internal/protected members | Only public API is exported | Use `public` modifier | | Expect/actual declarations | KMP's responsibility | Use platform-specific source sets | +| Storing a JVM callback on the native side | Upcall stubs are released when the call returns | Expose events as `Flow` | +| NUL characters in `String` arguments | Native strings are NUL-terminated; rejected with `IllegalArgumentException` | Encode the data (e.g. `ByteArray`) | | `ByteArray` in collections | Buffer lifecycle complexity across FFM | Use `List` or Base64 String | | `ByteArray` as data class field | Out-param buffer not wired for DC fields | Use separate method or String | | `ByteArray` as callback param | Buffer lifecycle across callback boundary | Use String (Base64) | diff --git a/examples/calculator/src/jvmTest/kotlin/com/example/calculator/BufferTortureTest.kt b/examples/calculator/src/jvmTest/kotlin/com/example/calculator/BufferTortureTest.kt new file mode 100644 index 00000000..bb66b2f1 --- /dev/null +++ b/examples/calculator/src/jvmTest/kotlin/com/example/calculator/BufferTortureTest.kt @@ -0,0 +1,114 @@ +package com.example.calculator + +import kotlinx.coroutines.runBlocking +import kotlin.random.Random +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNull + +/** Every fixed-size transfer buffer of the generated bridges, pushed past its limit. */ +class BufferTortureTest { + + private val sizes = listOf(0, 1, 8_190, 8_191, 8_192, 8_193, 100_000) + + @Test + fun `data class string field beyond buffer`() { + Calculator(0).use { calc -> + for (n in sizes) { + val nv = calc.bigNamedValue(n) + assertEquals(n, nv.name.length, "n=$n") + assertEquals(n, nv.value) + } + } + } + + @Test + fun `list of data classes with large strings`() { + Calculator(0).use { calc -> + val list = calc.bigNamedValues(50, 20_000) + assertEquals(List(50) { NamedValue("v$it-" + "x".repeat(20_000), it) }, list) + } + } + + @Test + fun `data class bytearray field beyond buffer`() { + Calculator(0).use { calc -> + for (n in sizes) { + val p = calc.bigPayload(n) + assertEquals(n, p.name.length, "name n=$n") + assertContentEquals(ByteArray(n) { (it % 251).toByte() }, p.data, "data n=$n") + } + } + } + + @Test + fun `nullable string beyond buffer`() { + Calculator(0).use { calc -> + assertNull(calc.bigNullableString(-1)) + for (n in sizes) assertEquals("ü".repeat(n), calc.bigNullableString(n), "n=$n") + } + } + + @Test + fun `string property beyond buffer`() { + Calculator(0).use { calc -> + for (n in sizes) { + val s = "λ".repeat(n) + calc.label = s + assertEquals(s, calc.label, "n=$n") + } + } + } + + @Test + fun `extension function string return beyond buffer`() { + Calculator(0).use { calc -> + val prefix = "p".repeat(20_000) + assertEquals("$prefix: Calculator(current=0)", calc.describeWithPrefix(prefix)) + } + } + + @Test + fun `bytearray roundtrip beyond buffer`() { + Calculator(0).use { calc -> + for (n in sizes) { + val data = Random(n).nextBytes(n) + assertContentEquals(data, calc.echoBytes(data), "n=$n") + } + } + } + + @Test + fun `maps with non-string keys`() { + Calculator(0).use { calc -> + for (n in listOf(0, 1, 5_000)) { + assertEquals((0 until n).associateWith { "v$it" }, calc.bigIndexed(n), "int keys n=$n") + assertEquals((0 until n).associate { it.toLong() * 3_000_000_000L to it }, calc.longKeyMap(n), "long keys n=$n") + } + assertEquals(Operation.entries.associateWith { it.ordinal * 10 }, calc.enumKeyMap()) + } + } + + @Test + fun `short and byte lists`() { + Calculator(0).use { calc -> + for (n in listOf(0, 1, 300, 5_000)) { + assertEquals(List(n) { (it - 30_000).toShort() }, calc.shortList(n), "shorts n=$n") + assertEquals(List(n) { (it - 128).toByte() }, calc.byteList(n), "bytes n=$n") + assertEquals(List(n) { (it - 30_000).toShort() }, runBlocking { calc.delayedShorts(n) }, "suspend shorts n=$n") + } + } + } + + @Test + fun `closed object passed as parameter fails cleanly`() { + val manager = CalculatorManager() + val calc = Calculator(5) + assertEquals(6, manager.addWith(calc, 1)) + calc.close() + assertFailsWith { manager.addWith(calc, 1) } + assertFailsWith { manager.describe(calc) } + } +} diff --git a/examples/calculator/src/jvmTest/kotlin/com/example/calculator/MonkeyTest.kt b/examples/calculator/src/jvmTest/kotlin/com/example/calculator/MonkeyTest.kt new file mode 100644 index 00000000..9630d9c1 --- /dev/null +++ b/examples/calculator/src/jvmTest/kotlin/com/example/calculator/MonkeyTest.kt @@ -0,0 +1,238 @@ +package com.example.calculator + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.flow.take +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeoutOrNull +import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.atomic.AtomicInteger +import kotlin.random.Random +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import kotlin.test.fail + +/** + * Seeded monkey tests. Every failure reports its seed; replay one with `MONKEY_SEED=`, + * widen the search with `MONKEY_SEEDS=`. + */ +class MonkeyTest { + + private val seeds: List = System.getenv("MONKEY_SEED")?.let { listOf(it.toLong()) } + ?: List(System.getenv("MONKEY_SEEDS")?.toInt() ?: 25) { 1_000L + it } + + private fun forEachSeed(block: (seed: Long, random: Random) -> Unit) { + for (seed in seeds) { + try { + block(seed, Random(seed)) + } catch (t: Throwable) { + throw AssertionError("Monkey failure with seed=$seed (replay: MONKEY_SEED=$seed)", t) + } + } + } + + // ══════════════════════════════════════════════════════════════════════════ + // Model-based: every native result is checked against a pure JVM model + // ══════════════════════════════════════════════════════════════════════════ + + private class Model(var acc: Int, var label: String = "", var closed: Boolean = false) + + @Test + fun `model-based monkey`() = deadlockGuard(300) { + forEachSeed { seed, rnd -> + val pool = mutableListOf>() + fun fresh(): Pair { + val initial = rnd.nextInt() + return (Calculator(initial) to Model(initial)).also { pool += it } + } + fresh() + repeat(1_500) { step -> + if (pool.isEmpty() || rnd.nextInt(40) == 0) fresh() + val (calc, model) = pool.random(rnd) + val where = "seed=$seed step=$step" + if (model.closed) { + expectClosed(where) { calc.add(1) } + return@repeat + } + when (rnd.nextInt(26)) { + 0 -> { val v = rnd.nextInt(); model.acc += v; assertEquals(model.acc, calc.add(v), where) } + 1 -> { val v = rnd.nextInt(); model.acc -= v; assertEquals(model.acc, calc.subtract(v), where) } + 2 -> { val v = rnd.nextInt(-5, 5); model.acc *= v; assertEquals(model.acc, calc.multiply(v), where) } + 3 -> { + val d = rnd.nextInt(-3, 4) + if (d == 0) { + val e = runCatching { calc.divide(0) }.exceptionOrNull() + assertTrue(e is KotlinNativeException && e.message == "Division by zero", "$where: $e") + } else { + model.acc /= d + assertEquals(model.acc, calc.divide(d), where) + } + } + 4 -> { + val op = BinaryOp.entries.random(rnd) + val v = rnd.nextInt() + model.acc = op.apply(model.acc, v) + assertEquals(model.acc, calc.evaluate(op, v), where) + } + 5 -> assertEquals(Vector(model.acc, model.acc * 2), calc.toVector(), where) + 6 -> { val v = Vector(rnd.nextInt(), rnd.nextInt()); assertEquals(Vector(model.acc, model.acc) + v, calc.addVector(v), where) } + 7 -> { val s = randomString(rnd); assertEquals(s, calc.echo(s), where) } + 8 -> { val s = randomString(rnd); model.label = s; calc.label = s; assertEquals(s, calc.label, where) } + 9 -> { + val n = rnd.nextInt(0, 12_000) + assertEquals(List(n) { it }, calc.bigInts(n), where) + } + 10 -> { + val items = List(rnd.nextInt(0, 6_000)) { randomString(rnd, 12) } + assertEquals(items, calc.echoStrings(items), where) + } + 11 -> { + // Callback that sometimes throws: the original exception must surface and state stay intact + val failAt = rnd.nextInt(0, 4) + val result = runCatching { calc.callTwice { if (it == failAt) throw MonkeyException("cb $it") else it * 10 } } + if (failAt == 1 || failAt == 2) { + assertTrue(result.exceptionOrNull() is MonkeyException, "$where: ${result.exceptionOrNull()}") + } else { + assertEquals(30, result.getOrThrow(), where) + } + } + 12 -> { + val e = runCatching { calc.measureVector { v -> if (rnd.nextBoolean()) throw MonkeyException("m") else v.x } } + if (e.isSuccess) { model.acc = e.getOrThrow(); assertEquals(model.acc, calc.current, where) } + else assertTrue(e.exceptionOrNull() is MonkeyException, "$where: ${e.exceptionOrNull()}") + } + 13 -> { + val result = runBlocking { calc.delayedVector(rnd.nextLong(0, 3)) } + assertEquals(Vector(model.acc, model.acc * 2), result, where) + } + 14 -> { + val result = runBlocking { withTimeoutOrNull(rnd.nextLong(0, 3)) { calc.delayedEvaluate(BinaryOp.PLUS, 0, rnd.nextLong(0, 5)) } } + if (result != null) assertEquals(model.acc, result, where) + } + 15 -> { + val n = rnd.nextInt(0, 300) + val take = rnd.nextInt(1, n + 2) + assertEquals(List(n) { it }.take(take), runBlocking { calc.fastFlow(n).take(take).toList() }, where) + } + 16 -> { + val n = rnd.nextInt(0, 5_000) + assertEquals((0 until n).associateBy { "key_$it" }, calc.bigMap(n), where) + } + 17 -> { + val s = "\u0000" + randomString(rnd) + val e = runCatching { calc.echo(s) }.exceptionOrNull() + assertTrue(e is IllegalArgumentException, "$where: $e") + } + 18 -> assertEquals(model.label, calc.label, where) + 19 -> { + val len = rnd.nextInt(0, 30_000) + assertEquals("é".repeat(len), calc.bigString(len), where) + } + 20 -> { model.closed = true; calc.close() } + 21 -> { + val n = rnd.nextInt(0, 20_000) + assertEquals(NamedValue("n".repeat(n), n), calc.bigNamedValue(n), where) + } + 22 -> { + // Another pooled calculator as argument, possibly closed + val (other, otherModel) = pool.random(rnd) + val v = rnd.nextInt() + val result = runCatching { CalculatorManager().addWith(other, v) } + if (otherModel.closed) { + expectClosed(where) { result.getOrThrow() } + } else { + otherModel.acc += v + assertEquals(otherModel.acc, result.getOrThrow(), where) + } + } + 23 -> { + val n = rnd.nextInt(0, 3_000) + assertEquals((0 until n).associate { it.toLong() * 3_000_000_000L to it }, calc.longKeyMap(n), where) + } + 24 -> { + val n = rnd.nextInt(0, 20_000) + val p = calc.bigPayload(n) + assertEquals(BinaryPayload("p".repeat(n), ByteArray(n) { (it % 251).toByte() }), p, where) + } + else -> assertEquals(model.acc, calc.current, where) + } + } + pool.forEach { it.first.close() } + } + } + + // ══════════════════════════════════════════════════════════════════════════ + // Concurrent chaos: shared objects, random closes, cancellations, throwing callbacks + // ══════════════════════════════════════════════════════════════════════════ + + @Test + fun `concurrent chaos monkey`() = deadlockGuard(600) { + forEachSeed { seed, rootRandom -> + val shared = CopyOnWriteArrayList(List(4) { Calculator(rootRandom.nextInt(-100, 100)) }) + val operations = AtomicInteger() + runParallel(threads = 8, iterations = 1) { t, _ -> + val rnd = Random(seed * 31 + t) + repeat(400) { step -> + val where = "seed=$seed thread=$t step=$step" + if (rnd.nextInt(50) == 0) shared += Calculator(rnd.nextInt()) + val calc = shared.random(rnd) + val outcome = runCatching { chaosStep(calc, rnd, shared) } + operations.incrementAndGet() + outcome.exceptionOrNull()?.let { e -> + val allowed = e is IllegalStateException && e.message == "Native object has been closed" || + e is MonkeyException || + e is KotlinNativeException && e.message == "Division by zero" + if (!allowed) throw AssertionError("$where: unexpected ${e::class.simpleName}: ${e.message}", e) + } + } + } + shared.forEach { it.close() } + assertEquals(8 * 400, operations.get()) + } + } + + private fun chaosStep(calc: Calculator, rnd: Random, shared: MutableList) { + when (rnd.nextInt(19)) { + 0 -> calc.add(rnd.nextInt()) + 1 -> calc.divide(rnd.nextInt(-1, 2)) + 2 -> calc.evaluate(BinaryOp.entries.random(rnd), rnd.nextInt()) + 3 -> calc.toVector().also { assertEquals(it.x * 2, it.y) } + 4 -> calc.echo(randomString(rnd)).let { } + 5 -> calc.bigInts(rnd.nextInt(0, 9_000)).let { list -> list.forEachIndexed { i, v -> if (i != v) fail("bigInts[$i]=$v") } } + 6 -> calc.callTwice { if (rnd.nextInt(3) == 0) throw MonkeyException("cb") else it } + 7 -> calc.measureVector { v -> if (rnd.nextInt(4) == 0) calc.close(); v.x } + 8 -> runBlocking(Dispatchers.Default) { + List(rnd.nextInt(1, 20)) { async { withTimeoutOrNull(rnd.nextLong(0, 4)) { calc.delayedVector(rnd.nextLong(0, 6)) } } }.awaitAll() + } + 9 -> runBlocking { calc.fastFlow(rnd.nextInt(0, 2_000)).take(rnd.nextInt(1, 300)).toList() } + 10 -> runBlocking { withTimeoutOrNull(rnd.nextLong(0, 10)) { calc.infiniteFlow().take(rnd.nextInt(1, 5)).toList() } } + 11 -> if (rnd.nextInt(10) == 0) calc.close() + 12 -> calc.echoStrings(List(rnd.nextInt(0, 3_000)) { "x$it" }) + 13 -> calc.bigMap(rnd.nextInt(0, 6_000)).let { m -> if (m.size > 0 && m["key_0"] != 0) fail("bigMap corrupted") } + 14 -> if (rnd.nextInt(20) == 0) System.gc() + 15 -> CalculatorManager().addWith(shared.random(rnd), 1) + 16 -> calc.bigNamedValue(rnd.nextInt(0, 20_000)).let { if (it.name.length != it.value) fail("bigNamedValue truncated") } + 17 -> calc.transformWith(shared.random(rnd)) { a, b -> if (rnd.nextInt(5) == 0) throw MonkeyException("tw") else a.current + b.current } + else -> runBlocking { calc.delayedBigInts(rnd.nextInt(0, 6_000)) }.let { list -> list.forEachIndexed { i, v -> if (i != v) fail("delayedBigInts[$i]=$v") } } + } + } + + // ══════════════════════════════════════════════════════════════════════════ + // Helpers + // ══════════════════════════════════════════════════════════════════════════ + + private class MonkeyException(message: String) : RuntimeException(message) + + private fun expectClosed(where: String, block: () -> Unit) { + val e = runCatching(block).exceptionOrNull() + assertTrue(e is IllegalStateException && e.message == "Native object has been closed", "$where: expected closed, got $e") + } + + private val alphabet = listOf("a", "Z", "0", " ", "é", "日", "😀", "\t", "\n", "𝄞", "ß", "\\", "\"", "%s", "{}") + + private fun randomString(rnd: Random, maxPieces: Int = 40): String = + buildString { repeat(rnd.nextInt(0, maxPieces)) { append(alphabet.random(rnd)) } } +} diff --git a/examples/calculator/src/jvmTest/kotlin/com/example/calculator/RuntimeTortureTest.kt b/examples/calculator/src/jvmTest/kotlin/com/example/calculator/RuntimeTortureTest.kt new file mode 100644 index 00000000..d5718673 --- /dev/null +++ b/examples/calculator/src/jvmTest/kotlin/com/example/calculator/RuntimeTortureTest.kt @@ -0,0 +1,283 @@ +package com.example.calculator + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.take +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.runBlocking +import java.lang.ref.WeakReference +import java.util.concurrent.atomic.AtomicInteger +import kotlin.concurrent.thread +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** Torture tests for the generated FFM runtime: buffers, errors, lifecycle, coroutines. */ +class RuntimeTortureTest { + + // ══════════════════════════════════════════════════════════════════════════ + // Collections beyond the default transfer buffers + // ══════════════════════════════════════════════════════════════════════════ + + @Test + fun `large primitive lists and sets`() { + Calculator(0).use { calc -> + for (n in listOf(0, 1, 4095, 4096, 4097, 100_000)) { + assertEquals(List(n) { it }, calc.bigInts(n), "ints n=$n") + assertEquals(List(n) { it.toLong() * 1_000_000_007L }, calc.bigLongs(n), "longs n=$n") + assertEquals((0 until n).toSet(), calc.bigIntSet(n), "set n=$n") + } + } + } + + @Test + fun `large string lists beyond packed buffer`() { + Calculator(0).use { calc -> + for ((count, length) in listOf(1 to 20_000, 2_000 to 10, 10_000 to 100)) { + val expected = List(count) { i -> "s$i-" + "x".repeat(length) } + assertEquals(expected, calc.bigStrings(count, length), "count=$count length=$length") + } + } + } + + @Test + fun `large map`() { + Calculator(0).use { calc -> + val map = calc.bigMap(20_000) + assertEquals(20_000, map.size) + assertEquals(19_999, map["key_19999"]) + } + } + + @Test + fun `large string return`() { + Calculator(0).use { calc -> + assertEquals("é".repeat(1_000_000), calc.bigString(1_000_000)) + } + } + + @Test + fun `large collection params roundtrip`() { + Calculator(0).use { calc -> + val ints = List(100_000) { it * 3 } + assertEquals(ints, calc.echoInts(ints)) + assertEquals(ints.sumOf { it.toLong() }, calc.sumInts(ints)) + val strings = List(20_000) { "élément_$it" } + assertEquals(strings, calc.echoStrings(strings)) + } + } + + @Test + fun `large collection inside data class`() { + Calculator(0).use { calc -> + assertEquals(TaggedList("big", List(10_000) { it }), calc.bigTaggedList(10_000)) + } + } + + @Test + fun `large suspend collection returns`() = runBlocking { + Calculator(0).use { calc -> + assertEquals(List(50_000) { it }, calc.delayedBigInts(50_000)) + assertEquals(List(5_000) { "item_$it" }, calc.delayedBigStrings(5_000)) + } + } + + // ══════════════════════════════════════════════════════════════════════════ + // Strings + // ══════════════════════════════════════════════════════════════════════════ + + @Test + fun `unicode strings roundtrip`() { + Calculator(0).use { calc -> + for (s in listOf("", "a", "日本語", "😀👨‍👩‍👧‍👦", "\t\n\r", "é".repeat(10_000), "𝄞".repeat(5_000))) { + assertEquals(s, calc.echo(s)) + } + } + } + + @Test + fun `strings with embedded NUL are not silently truncated`() { + Calculator(0).use { calc -> + val s = "before\u0000after" + val result = runCatching { calc.echo(s) } + // Either a faithful roundtrip or an explicit failure — never silent truncation + result.onSuccess { assertEquals(s, it) } + result.onFailure { assertTrue(it is IllegalArgumentException, "unexpected $it") } + } + } + + // ══════════════════════════════════════════════════════════════════════════ + // Error propagation under concurrency + // ══════════════════════════════════════════════════════════════════════════ + + @Test + fun `native errors never leak to another thread`() = deadlockGuard(60) { + val spurious = AtomicInteger() + val lost = AtomicInteger() + runParallel(threads = 16, iterations = 20_000) { t, _ -> + Calculator(1).use { calc -> + if (t % 2 == 0) { + if (runCatching { calc.divide(0) }.isSuccess) lost.incrementAndGet() + } else { + if (runCatching { calc.add(1) }.isFailure) spurious.incrementAndGet() + } + } + } + assertEquals(0, lost.get(), "errors lost") + assertEquals(0, spurious.get(), "errors raised on the wrong thread") + } + + @Test + fun `callback exceptions keep their type and message`() { + Calculator(0).use { calc -> + val e = assertFailsWith { calc.callTwice { if (it == 2) throw ArithmeticException("second") else it } } + assertEquals("second", e.message) + assertEquals(5, calc.callTwice { it + 1 }) + } + } + + @Test + fun `callback exceptions under concurrency stay on their thread`() = deadlockGuard(60) { + Calculator(0).use { calc -> + runParallel(threads = 16, iterations = 2_000) { t, i -> + if ((t + i) % 2 == 0) { + val e = assertFailsWith { calc.callTwice { error("t$t-i$i") } } + assertEquals("t$t-i$i", e.message) + } else { + assertEquals(3, calc.callTwice { it }) + } + } + } + } + + // ══════════════════════════════════════════════════════════════════════════ + // Object lifecycle + // ══════════════════════════════════════════════════════════════════════════ + + @Test + fun `use after close fails cleanly`() { + val calc = Calculator(1) + calc.close() + assertFailsWith { calc.add(1) } + assertFailsWith { calc.current } + calc.close() // idempotent + } + + @Test + fun `concurrent close is safe`() = deadlockGuard(60) { + repeat(2_000) { + val calc = Calculator(1) + runParallel(threads = 8, iterations = 1) { _, _ -> calc.close() } + } + } + + @Test + fun `close racing with calls never crashes`() = deadlockGuard(60) { + repeat(500) { + val calc = Calculator(1) + val closer = thread { Thread.sleep(0, 50_000); calc.close() } + runParallel(threads = 4, iterations = 200) { _, _ -> + runCatching { calc.toVector() }.onFailure { assertTrue(it is IllegalStateException, "unexpected $it") } + } + closer.join() + } + } + + @Test + fun `close from inside a callback does not deadlock`() = deadlockGuard(30) { + val calc = Calculator(4) + assertEquals(8, calc.measureVector { v -> calc.close(); v.x * 2 }) + assertFailsWith { calc.add(1) } + } + + @Test + fun `unclosed objects are reclaimed by the cleaner`() { + val refs = List(1_000) { WeakReference(Calculator(it)) } + repeat(50) { + System.gc() + if (refs.all { it.get() == null }) return + Thread.sleep(20) + } + val alive = refs.count { it.get() != null } + assertEquals(0, alive, "$alive proxies still strongly reachable") + } + + @Test + fun `objects used with callbacks and coroutines are still reclaimed`() { + val refs = List(200) { i -> + val calc = Calculator(i) + calc.callTwice { it + calc.current } + calc.measureVector { it.x } + runBlocking { calc.delayedVector(0); calc.fastFlow(3).toList() } + WeakReference(calc) + } + repeat(50) { + System.gc() + if (refs.all { it.get() == null }) return + Thread.sleep(20) + } + assertEquals(0, refs.count { it.get() != null }, "proxies kept alive by upcall stubs") + } + + // ══════════════════════════════════════════════════════════════════════════ + // Coroutines & flows + // ══════════════════════════════════════════════════════════════════════════ + + @Test + fun `fast flow with slow collector loses nothing`() = runBlocking { + Calculator(0).use { calc -> + var expected = 0 + calc.fastFlow(20_000).collect { v -> + assertEquals(expected++, v) + if (v % 2_000 == 0) delay(5) + } + assertEquals(20_000, expected) + } + } + + @Test + fun `completed flows keep close waiting for pending suspend calls`() = deadlockGuard(60) { + runBlocking(Dispatchers.Default) { + val calc = Calculator(5) + repeat(5) { assertEquals(listOf(0, 1, 2), calc.fastFlow(3).toList()) } + val pending = async { calc.delayedVector(200) } + delay(20) + calc.close() + assertEquals(Vector(5, 10), pending.await()) + } + } + + @Test + fun `flow collector exception propagates`() = runBlocking { + Calculator(0).use { calc -> + val e = assertFailsWith { calc.fastFlow(1_000).collect { if (it == 500) error("stop") } } + assertEquals("stop", e.message) + assertEquals(listOf(0, 1), calc.fastFlow(2).toList()) + } + } + + @Test + fun `thousands of early flow cancellations`() = deadlockGuard(60) { + runBlocking(Dispatchers.Default) { + Calculator(0).use { calc -> + List(2_000) { async { calc.infiniteFlow().take(1).toList() } }.awaitAll().forEach { assertEquals(listOf(0), it) } + List(2_000) { async { calc.fastFlow(1_000_000).first() } }.awaitAll().forEach { assertEquals(0, it) } + } + } + } + + @Test + fun `suspend cancelled before native start does not leak`() = deadlockGuard(60) { + val calc = Calculator(11) + runBlocking(Dispatchers.Default) { + List(5_000) { async { kotlinx.coroutines.withTimeoutOrNull(0) { calc.delayedVector(10) } } }.awaitAll() + .forEach { assertNull(it) } + } + calc.close() + } +} diff --git a/examples/calculator/src/jvmTest/kotlin/com/example/calculator/TortureSupport.kt b/examples/calculator/src/jvmTest/kotlin/com/example/calculator/TortureSupport.kt new file mode 100644 index 00000000..422ad870 --- /dev/null +++ b/examples/calculator/src/jvmTest/kotlin/com/example/calculator/TortureSupport.kt @@ -0,0 +1,43 @@ +package com.example.calculator + +import java.lang.management.ManagementFactory +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.CyclicBarrier +import java.util.concurrent.ExecutionException +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import java.util.concurrent.TimeoutException +import kotlin.concurrent.thread +import kotlin.test.fail + +/** Runs [block] on a worker and fails with a full thread dump if it does not finish in time. */ +internal fun deadlockGuard(seconds: Long, block: () -> Unit) { + val executor = Executors.newSingleThreadExecutor() + try { + executor.submit(block).get(seconds, TimeUnit.SECONDS) + } catch (_: TimeoutException) { + val dump = ManagementFactory.getThreadMXBean().dumpAllThreads(true, true).joinToString("") + fail("Probable deadlock (no completion in ${seconds}s)\n$dump") + } catch (e: ExecutionException) { + throw e.cause ?: e + } finally { + executor.shutdownNow() + } +} + +/** Starts [threads] threads behind a barrier, each running [body] [iterations] times; rethrows the first failure. */ +internal fun runParallel(threads: Int, iterations: Int, body: (thread: Int, iteration: Int) -> Unit) { + val barrier = CyclicBarrier(threads) + val errors = ConcurrentHashMap.newKeySet() + List(threads) { t -> + thread { + try { + barrier.await() + repeat(iterations) { i -> body(t, i) } + } catch (e: Throwable) { + errors += e + } + } + }.forEach { it.join() } + errors.firstOrNull()?.let { throw AssertionError("${errors.size} thread(s) failed", it) } +} diff --git a/examples/calculator/src/jvmTest/kotlin/com/example/calculator/ValueTypeParityTest.kt b/examples/calculator/src/jvmTest/kotlin/com/example/calculator/ValueTypeParityTest.kt new file mode 100644 index 00000000..df20e079 --- /dev/null +++ b/examples/calculator/src/jvmTest/kotlin/com/example/calculator/ValueTypeParityTest.kt @@ -0,0 +1,78 @@ +package com.example.calculator + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +/** JVM enums and data classes are verbatim copies of the native declarations. */ +class ValueTypeParityTest { + + @Test + fun `enum entry bodies are available on JVM`() { + assertEquals(5, BinaryOp.PLUS.apply(2, 3)) + assertEquals(-1, BinaryOp.MINUS.apply(2, 3)) + assertEquals(6, BinaryOp.TIMES.apply(2, 3)) + } + + @Test + fun `enum companion is available on JVM`() { + assertEquals(BinaryOp.TIMES, BinaryOp.fromSymbol("*")) + assertNull(BinaryOp.fromSymbol("/")) + } + + @Test + fun `enum with bodies crosses FFM as param`() { + Calculator(10).use { calc -> + for (op in BinaryOp.entries) { + val expected = op.apply(calc.current, 3) + assertEquals(expected, calc.evaluate(op, 3)) + } + } + } + + @Test + fun `enum with bodies crosses FFM as return`() { + Calculator(1).use { assertEquals(BinaryOp.PLUS, it.lastBinaryOp()) } + Calculator(-1).use { assertEquals(BinaryOp.MINUS, it.lastBinaryOp()) } + } + + @Test + fun `data class default value is available on JVM`() { + assertEquals(Vector(4, 0), Vector(4)) + } + + @Test + fun `data class body members are available on JVM`() { + val v = Vector(3, -4) + assertEquals(25, v.lengthSquared) + assertEquals(7, v.manhattan) + assertEquals(Vector(4, -2), v + Vector(1, 2)) + assertEquals(Vector(6, -8), v.scaled(2)) + } + + @Test + fun `data class with body crosses FFM`() { + Calculator(3).use { calc -> + val v = calc.toVector() + assertEquals(Vector(3, 6), v) + assertEquals(45, v.lengthSquared) + assertEquals(Vector(4, 5), calc.addVector(Vector(1, 2))) + } + } + + @Test + fun `JVM and native compute the same derived value`() { + Calculator(0).use { calc -> + val v = Vector(7, -2) + assertEquals(v.lengthSquared, calc.vectorLengthSquared(v)) + } + } + + @Test + fun `data class equals override is preserved on JVM`() { + val a = BinaryPayload("p", byteArrayOf(1, 2, 3)) + val b = BinaryPayload("p", byteArrayOf(1, 2, 3)) + assertEquals(a, b) + assertEquals(a.hashCode(), b.hashCode()) + } +} diff --git a/examples/calculator/src/jvmTest/kotlin/com/example/calculator/ValueTypeTortureTest.kt b/examples/calculator/src/jvmTest/kotlin/com/example/calculator/ValueTypeTortureTest.kt new file mode 100644 index 00000000..c542f555 --- /dev/null +++ b/examples/calculator/src/jvmTest/kotlin/com/example/calculator/ValueTypeTortureTest.kt @@ -0,0 +1,280 @@ +package com.example.calculator + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeoutOrNull +import java.net.URLClassLoader +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.CyclicBarrier +import java.util.concurrent.atomic.AtomicInteger +import kotlin.concurrent.thread +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** Extreme values, races and deadlock probes on verbatim JVM enums / data classes crossing FFM. */ +class ValueTypeTortureTest { + + private val extremes = listOf(Int.MIN_VALUE, Int.MIN_VALUE + 1, -1, 0, 1, Int.MAX_VALUE - 1, Int.MAX_VALUE) + + // ══════════════════════════════════════════════════════════════════════════ + // Extreme values — JVM copy and native must agree bit for bit + // ══════════════════════════════════════════════════════════════════════════ + + @Test + fun `overflow parity - every op on every extreme pair`() { + for (a in extremes) for (b in extremes) for (op in BinaryOp.entries) { + Calculator(a).use { calc -> + assertEquals(op.apply(a, b), calc.evaluate(op, b), "$a ${op.symbol} $b") + } + } + } + + @Test + fun `overflow parity - vector members on extreme components`() { + for (x in extremes) for (y in extremes) { + val v = Vector(x, y) + Calculator(x).use { calc -> + assertEquals(v.lengthSquared, calc.vectorLengthSquared(v), "lengthSquared($x, $y)") + assertEquals(Vector(x, x) + Vector(y, y), calc.addVector(Vector(y, y))) + assertEquals(Vector(x, x * 2), calc.toVector()) + } + } + } + + @Test + fun `fromSymbol parity on odd strings`() { + val inputs = listOf("", "+", "-", "*", "/", "++", " +", "+ ", "日本語", "😀", "\t", "+".repeat(1_000_000)) + Calculator(0).use { calc -> + for (s in inputs) assertEquals(BinaryOp.fromSymbol(s), calc.findBinaryOp(s), "symbol '${s.take(10)}'") + } + } + + @Test + fun `nullable data class with body`() { + Calculator(Int.MAX_VALUE).use { calc -> + assertEquals(Vector(Int.MAX_VALUE, -2), calc.vectorOrNull(true)) + assertNull(calc.vectorOrNull(false)) + } + } + + @Test + fun `list of data classes - empty, single, large`() { + Calculator(0).use { calc -> + assertEquals(emptyList(), calc.vectorsUpTo(0)) + assertEquals(listOf(Vector(0, 0)), calc.vectorsUpTo(1)) + val big = calc.vectorsUpTo(10_000) + assertEquals(10_000, big.size) + big.forEachIndexed { i, v -> + assertEquals(Vector(i, -i), v) + assertEquals(2 * i, v.manhattan) + } + } + } + + @Test + fun `list of enums with bodies - empty and large`() { + Calculator(0).use { calc -> + assertEquals(emptyList(), calc.binaryOpsCycle(0)) + val ops = calc.binaryOpsCycle(10_000) + assertEquals(10_000, ops.size) + ops.forEachIndexed { i, op -> assertEquals(BinaryOp.entries[i % 3], op) } + assertEquals(3, ops.toSet().size) + } + } + + @Test + fun `callbacks receive and return extreme value types`() { + for (x in extremes) Calculator(x).use { calc -> + assertEquals(Vector(x, Int.MIN_VALUE), calc.buildVector { Vector(it, Int.MIN_VALUE) }) + assertEquals(Vector(x, x * 2).manhattan, calc.measureVector { it.manhattan }) + for (op in BinaryOp.entries) assertEquals(op, calc.pickBinaryOp { op }) + } + } + + @Test + fun `callback throwing leaves calculator usable`() { + Calculator(3).use { calc -> + repeat(1_000) { + val e1 = assertFailsWith { calc.measureVector { error("boom $it") } } + assertEquals("boom Vector(x=3, y=6)", e1.message) + assertFailsWith { calc.buildVector { throw UnsupportedOperationException() } } + } + assertEquals(Vector(3, 6), calc.toVector()) + assertEquals(5, calc.evaluate(BinaryOp.PLUS, 2)) + } + } + + // ══════════════════════════════════════════════════════════════════════════ + // Races + // ══════════════════════════════════════════════════════════════════════════ + + @Test + fun `concurrent first-time class init of verbatim enum and data class`() = deadlockGuard(60) { + val classesDir = BinaryOp::class.java.protectionDomain.codeSource.location + repeat(30) { + val loader = ChildFirstLoader(arrayOf(classesDir), javaClass.classLoader) + val threads = 16 + val barrier = CyclicBarrier(threads) + val errors = ConcurrentHashMap.newKeySet() + val workers = List(threads) { t -> + thread { + try { + barrier.await() + val op = Class.forName("com.example.calculator.BinaryOp", true, loader) + when (t % 4) { + 0 -> op.getField("PLUS").get(null) + 1 -> op.getMethod("values").invoke(null) + 2 -> op.getMethod("valueOf", String::class.java).invoke(null, "TIMES") + else -> { + val companion = op.getField("Companion").get(null) + companion.javaClass.getMethod("fromSymbol", String::class.java).invoke(companion, "-") + } + } + val vec = Class.forName("com.example.calculator.Vector", true, loader) + vec.getConstructor(Int::class.java, Int::class.java).newInstance(t, t) + } catch (e: Throwable) { + errors += e + } + } + } + workers.forEach { it.join() } + assertTrue(errors.isEmpty(), "init errors: $errors") + } + } + + @Test + fun `shared calculator - concurrent identity ops keep invariants`() = deadlockGuard(60) { + Calculator(21).use { calc -> + runParallel(threads = 16, iterations = 5_000) { _, i -> + when (i % 5) { + 0 -> assertEquals(21, calc.evaluate(BinaryOp.PLUS, 0)) + 1 -> assertEquals(21, calc.evaluate(BinaryOp.TIMES, 1)) + 2 -> assertEquals(Vector(21, 42), calc.toVector()) + 3 -> assertEquals(Vector(22, 23), calc.addVector(Vector(1, 2))) + else -> assertEquals(BinaryOp.PLUS, calc.lastBinaryOp()) + } + } + assertEquals(21, calc.current) + } + } + + @Test + fun `shared calculator - concurrent mutations never crash`() = deadlockGuard(60) { + Calculator(0).use { calc -> + runParallel(threads = 16, iterations = 5_000) { _, _ -> calc.evaluate(BinaryOp.PLUS, 1) } + // Native accumulator is not atomic: lost updates are allowed, corruption is not + assertTrue(calc.current in 1..80_000, "current=${calc.current}") + } + } + + @Test + fun `per-thread calculators match JVM simulation exactly`() = deadlockGuard(60) { + runParallel(threads = 16, iterations = 1) { t, _ -> + Calculator(t).use { calc -> + var expected = t + repeat(5_000) { i -> + val op = BinaryOp.entries[(i + t) % 3] + val operand = (i % 7) - 3 + expected = op.apply(expected, operand) + assertEquals(expected, calc.evaluate(op, operand)) + } + assertEquals(Vector(expected, expected * 2), calc.toVector()) + } + } + } + + @Test + fun `native vectors hash consistently across threads`() = deadlockGuard(60) { + val seen = ConcurrentHashMap() + Calculator(7).use { calc -> + runParallel(threads = 16, iterations = 2_000) { _, _ -> + seen.computeIfAbsent(calc.toVector()) { AtomicInteger() }.incrementAndGet() + } + } + assertEquals(setOf(Vector(7, 14)), seen.keys) + assertEquals(32_000, seen.getValue(Vector(7, 14)).get()) + } + + // ══════════════════════════════════════════════════════════════════════════ + // Deadlock probes + // ══════════════════════════════════════════════════════════════════════════ + + @Test + fun `re-entrant callbacks into the same native object`() = deadlockGuard(60) { + Calculator(5).use { calc -> + runParallel(threads = 8, iterations = 500) { _, _ -> + val nested = calc.buildVector { outer -> + val inner = calc.buildVector { Vector(it, outer) } + Vector(calc.vectorLengthSquared(inner), calc.pickBinaryOp { BinaryOp.MINUS }.apply(outer, 1)) + } + assertEquals(Vector(50, 4), nested) + } + } + } + + @Test + fun `callback blocks on another thread calling native`() = deadlockGuard(30) { + Calculator(9).use { calc -> + repeat(200) { + val result = calc.buildVector { x -> + var fromOtherThread: Vector? = null + thread { fromOtherThread = calc.toVector() }.join() + Vector(x, fromOtherThread!!.y) + } + assertEquals(Vector(9, 18), result) + } + } + } + + @Test + fun `thousands of suspend calls with cancellation on a shared object`() = deadlockGuard(120) { + Calculator(11).use { calc -> + val results = runBlocking(Dispatchers.Default) { + List(2_000) { i -> + async { + if (i % 2 == 0) { + withTimeoutOrNull(1) { calc.delayedVector(50) } + } else { + val op = if (i % 4 == 1) BinaryOp.PLUS else BinaryOp.TIMES + calc.delayedEvaluate(op, if (op == BinaryOp.PLUS) 0 else 1, 5) + } + } + }.awaitAll() + } + results.forEachIndexed { i, r -> + if (i % 2 == 0) assertTrue(r == null || r == Vector(11, 22), "#$i -> $r") + else assertEquals(11, r) + } + // Object must stay usable after mass cancellation + assertEquals(Vector(11, 22), runBlocking { calc.delayedVector(1) }) + } + } + + @Test + fun `gc pressure with unclosed calculators producing value types`() = deadlockGuard(120) { + runParallel(threads = 8, iterations = 5_000) { t, i -> + val calc = Calculator(i) + assertEquals(Vector(i, i * 2), calc.toVector()) + assertEquals(BinaryOp.PLUS, calc.lastBinaryOp()) + if (i % 1_000 == 0 && t == 0) System.gc() + } + } + + // ══════════════════════════════════════════════════════════════════════════ + // Helpers + // ══════════════════════════════════════════════════════════════════════════ + + /** Loads the value types afresh so that class initialization races actually happen. */ + private class ChildFirstLoader(urls: Array, parent: ClassLoader) : URLClassLoader(urls, parent) { + override fun loadClass(name: String, resolve: Boolean): Class<*> = synchronized(getClassLoadingLock(name)) { + val isValueType = name.startsWith("com.example.calculator.BinaryOp") || name.startsWith("com.example.calculator.Vector") + if (!isValueType) return super.loadClass(name, resolve) + findLoadedClass(name) ?: findClass(name) + } + } +} diff --git a/examples/calculator/src/nativeMain/kotlin/com/example/calculator/Calculator.kt b/examples/calculator/src/nativeMain/kotlin/com/example/calculator/Calculator.kt index 7c24b3ee..4df2e07a 100644 --- a/examples/calculator/src/nativeMain/kotlin/com/example/calculator/Calculator.kt +++ b/examples/calculator/src/nativeMain/kotlin/com/example/calculator/Calculator.kt @@ -952,6 +952,111 @@ class Calculator(initial: Int = 0) { fun getNullableMapByLabel(): Map? = if (label.isEmpty()) null else mapOf("label" to label) + // ── Value types with bodies (verbatim on JVM) ─────────────────────────── + + fun evaluate(op: BinaryOp, value: Int): Int { + accumulator = op.apply(accumulator, value) + return accumulator + } + + fun lastBinaryOp(): BinaryOp = if (accumulator >= 0) BinaryOp.PLUS else BinaryOp.MINUS + + fun toVector(): Vector = Vector(accumulator, accumulator * 2) + + fun addVector(v: Vector): Vector = Vector(accumulator, accumulator) + v + + fun vectorLengthSquared(v: Vector): Int = v.lengthSquared + + fun findBinaryOp(symbol: String): BinaryOp? = BinaryOp.fromSymbol(symbol) + + fun vectorOrNull(present: Boolean): Vector? = if (present) toVector() else null + + fun vectorsUpTo(count: Int): List = List(count) { Vector(it, -it) } + + fun binaryOpsCycle(count: Int): List = List(count) { BinaryOp.entries[it % BinaryOp.entries.size] } + + fun buildVector(factory: (Int) -> Vector): Vector = factory(accumulator) + + fun measureVector(fn: (Vector) -> Int): Int { + accumulator = fn(toVector()) + return accumulator + } + + fun pickBinaryOp(chooser: (Int) -> BinaryOp): BinaryOp = chooser(accumulator) + + suspend fun delayedVector(delayMs: Long): Vector { + kotlinx.coroutines.delay(delayMs) + return toVector() + } + + suspend fun delayedEvaluate(op: BinaryOp, value: Int, delayMs: Long): Int { + kotlinx.coroutines.delay(delayMs) + return evaluate(op, value) + } + + // ── Runtime torture helpers ───────────────────────────────────────────── + + fun bigInts(count: Int): List = List(count) { it } + + fun bigLongs(count: Int): List = List(count) { it.toLong() * 1_000_000_007L } + + fun bigIntSet(count: Int): Set = (0 until count).toSet() + + fun bigStrings(count: Int, length: Int): List = List(count) { i -> "s$i-" + "x".repeat(length) } + + fun bigMap(count: Int): Map = (0 until count).associate { "key_$it" to it } + + fun bigString(length: Int): String = "é".repeat(length) + + fun echoInts(items: List): List = items + + fun echoStrings(items: List): List = items + + fun sumInts(items: List): Long = items.sumOf { it.toLong() } + + fun bigTaggedList(count: Int): TaggedList = TaggedList("big", List(count) { it }) + + suspend fun delayedBigInts(count: Int): List { + kotlinx.coroutines.delay(1) + return List(count) { it } + } + + suspend fun delayedBigStrings(count: Int): List { + kotlinx.coroutines.delay(1) + return List(count) { "item_$it" } + } + + fun fastFlow(count: Int): kotlinx.coroutines.flow.Flow = kotlinx.coroutines.flow.flow { + repeat(count) { emit(it) } + } + + fun callTwice(fn: (Int) -> Int): Int = fn(1) + fn(2) + + fun bigNamedValue(length: Int): NamedValue = NamedValue("n".repeat(length), length) + + fun bigNamedValues(count: Int, length: Int): List = List(count) { NamedValue("v$it-" + "x".repeat(length), it) } + + fun bigPayload(size: Int): BinaryPayload = BinaryPayload("p".repeat(size), ByteArray(size) { (it % 251).toByte() }) + + fun bigNullableString(length: Int): String? = if (length < 0) null else "ü".repeat(length) + + fun bigIndexed(count: Int): Map = (0 until count).associateWith { "v$it" } + + fun longKeyMap(count: Int): Map = (0 until count).associate { it.toLong() * 3_000_000_000L to it } + + fun enumKeyMap(): Map = Operation.entries.associateWith { it.ordinal * 10 } + + fun shortList(count: Int): List = List(count) { (it - 30_000).toShort() } + + fun byteList(count: Int): List = List(count) { (it - 128).toByte() } + + suspend fun delayedShorts(count: Int): List { + kotlinx.coroutines.delay(1) + return shortList(count) + } + + fun echoBytes(data: ByteArray): ByteArray = data + // ── Companion object ──────────────────────────────────────────────────── companion object { diff --git a/examples/calculator/src/nativeMain/kotlin/com/example/calculator/ValueTypes.kt b/examples/calculator/src/nativeMain/kotlin/com/example/calculator/ValueTypes.kt new file mode 100644 index 00000000..e77d364b --- /dev/null +++ b/examples/calculator/src/nativeMain/kotlin/com/example/calculator/ValueTypes.kt @@ -0,0 +1,33 @@ +package com.example.calculator + +import kotlin.math.abs + +/** Binary operator carrying its own evaluation logic. */ +enum class BinaryOp(val symbol: String) { + PLUS("+") { + override fun apply(a: Int, b: Int): Int = a + b + }, + MINUS("-") { + override fun apply(a: Int, b: Int): Int = a - b + }, + TIMES("*") { + override fun apply(a: Int, b: Int): Int = a * b + }; + + abstract fun apply(a: Int, b: Int): Int + + companion object { + fun fromSymbol(symbol: String): BinaryOp? = entries.firstOrNull { it.symbol == symbol } + } +} + +/** 2D vector with derived helpers. */ +data class Vector(val x: Int, val y: Int = 0) { + val lengthSquared: Int get() = x * x + y * y + + val manhattan: Int get() = abs(x) + abs(y) + + operator fun plus(other: Vector): Vector = Vector(x + other.x, y + other.y) + + fun scaled(factor: Int): Vector = Vector(x * factor, y * factor) +} diff --git a/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/nna/plugin/analysis/PsiSourceParser.kt b/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/nna/plugin/analysis/PsiSourceParser.kt index a63bab88..c4dcaaa6 100644 --- a/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/nna/plugin/analysis/PsiSourceParser.kt +++ b/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/nna/plugin/analysis/PsiSourceParser.kt @@ -10,6 +10,7 @@ import dev.nucleusframework.nna.plugin.ir.KneInterface import dev.nucleusframework.nna.plugin.ir.KneModule import dev.nucleusframework.nna.plugin.ir.KneParam import dev.nucleusframework.nna.plugin.ir.KneProperty +import dev.nucleusframework.nna.plugin.ir.KneSourceDecl import dev.nucleusframework.nna.plugin.ir.KneType import org.jetbrains.kotlin.cli.jvm.compiler.KotlinCoreApplicationEnvironment import org.jetbrains.kotlin.cli.jvm.compiler.KotlinCoreApplicationEnvironmentMode @@ -135,17 +136,27 @@ class PsiSourceParser { val ktFile = psiFactory.createFile(file.name, file.readText()) val pkg = ktFile.packageFqName.asString() if (pkg.isNotEmpty()) packages.add(pkg) + val imports = ktFile.importDirectives.map { it.text } fun processDeclarations(declarations: List, parentSimpleName: String?, isTopLevel: Boolean) { for (decl in declarations) { if (decl.isPrivateOrInternal()) continue when { - decl is KtClass && decl.isEnum() -> parseEnum(decl, pkg, typeMaps)?.let { enumMap.putIfAbsent(it.fqName, it) } + decl is KtClass && decl.isEnum() -> parseEnum(decl, pkg, typeMaps) + ?.copy(source = sourceDecl(decl, imports)) + ?.let { enumMap.putIfAbsent(it.fqName, it) } decl is KtClass && decl.isData() -> { val name = decl.name ?: continue val dcInfo = knownDataClasses[name] ?: continue val fq = dcInfo.first - dataClassMap.putIfAbsent(fq, KneDataClass(name, fq, dcInfo.second, isCommon = name in commonDataClassNames)) + dataClassMap.putIfAbsent( + fq, + KneDataClass( + name, fq, dcInfo.second, + isCommon = name in commonDataClassNames, + source = sourceDecl(decl, imports), + ), + ) } decl is KtClass && decl.isInterface() -> { val iface = parseInterface(decl, pkg, typeMaps, isCommon = isCommonFile) ?: continue @@ -217,6 +228,13 @@ class PsiSourceParser { return if (fields.isNotEmpty()) fields else null } + /** Captures the declaration text (incl. KDoc and annotations) re-indented to top level. */ + private fun sourceDecl(decl: KtClass, imports: List): KneSourceDecl { + val indent = decl.prevSibling?.text?.substringAfterLast('\n', missingDelimiterValue = "")?.takeIf { it.isBlank() }.orEmpty() + val text = (indent + decl.text).trimIndent() + return KneSourceDecl(text, imports) + } + private fun parseClass(ktClass: KtClass, pkg: String, typeMaps: TypeMaps, isCommon: Boolean = false): KneClass? { val name = ktClass.name ?: return null val fq = if (pkg.isNotEmpty()) "$pkg.$name" else name diff --git a/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/nna/plugin/codegen/FfmProxyGenerator.kt b/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/nna/plugin/codegen/FfmProxyGenerator.kt index 054450e7..bbaea528 100644 --- a/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/nna/plugin/codegen/FfmProxyGenerator.kt +++ b/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/nna/plugin/codegen/FfmProxyGenerator.kt @@ -7,6 +7,7 @@ import dev.nucleusframework.nna.plugin.ir.KneFunction import dev.nucleusframework.nna.plugin.ir.KneModule import dev.nucleusframework.nna.plugin.ir.KneParam import dev.nucleusframework.nna.plugin.ir.KneProperty +import dev.nucleusframework.nna.plugin.ir.KneSourceDecl import dev.nucleusframework.nna.plugin.ir.KneType /** @@ -41,6 +42,329 @@ class FfmProxyGenerator { "JAVA_SHORT" to "short", "ADDRESS" to "void*", ) + + /** + * Static runtime support appended inside `object KneRuntime` (LIB_NAME is substituted). + * Covers callback exception propagation, object leases, and buffer overflow recovery. + */ + private val RUNTIME_SUPPORT = """ + + // ── Callback exceptions ────────────────────────────────────────────── + + private const val CALLBACK_ERROR_PREFIX = "kne-callback-error:" + private val SET_CALLBACK_ERROR_HANDLE: MethodHandle by lazy { + handle("LIB_NAME_kne_setCallbackError", FunctionDescriptor.ofVoid(JAVA_LONG)) + } + private val pendingCallbackErrors = java.util.concurrent.ConcurrentHashMap() + private val callbackErrorIds = java.util.concurrent.atomic.AtomicLong() + + /** Parks [t] thrown by a JVM callback and flags the native caller so it unwinds instead of using a bogus result. */ + fun reportCallbackError(t: Throwable) { + val id = callbackErrorIds.incrementAndGet() + pendingCallbackErrors[id] = t + SET_CALLBACK_ERROR_HANDLE.invoke(id) + } + + /** Maps a native error message back to the original JVM exception when it came from a callback. */ + fun exceptionFor(message: String): Throwable { + if (message.startsWith(CALLBACK_ERROR_PREFIX)) { + val id = message.removePrefix(CALLBACK_ERROR_PREFIX).toLongOrNull() + if (id != null) pendingCallbackErrors.remove(id)?.let { return it } + } + return KotlinNativeException(message) + } + + /** Exceptions must never escape an upcall (FFM would terminate the VM): route them to the thread handler. */ + fun uncaught(t: Throwable) { + val thread = Thread.currentThread() + runCatching { thread.uncaughtExceptionHandler.uncaughtException(thread, t) } + } + + // ── Strings ────────────────────────────────────────────────────────── + + /** Native strings are NUL-terminated: reject embedded NULs instead of silently truncating them. */ + fun cstr(arena: Arena, value: String): MemorySegment { + require(value.indexOf('\u0000') < 0) { "Strings passed to native code cannot contain NUL characters" } + return arena.allocateFrom(value) + } + + /** Reads a string out-param; a leading 0xFF marks an oversized value parked natively as a StableRef. */ + fun outString(segment: MemorySegment): String = + if (segment.get(JAVA_BYTE, 0) == 0xFF.toByte()) readStringFromRef(segment.getString(1).toLong()) + else segment.getString(0) + + /** Decodes [length] UTF-8 bytes, so that NUL characters returned by native code survive. */ + fun utf8(segment: MemorySegment, length: Int): String = + String(segment.asSlice(0, length.toLong()).toArray(JAVA_BYTE), Charsets.UTF_8) + + // ── StableRef results ──────────────────────────────────────────────── + + val DISPOSE_REF_HANDLE: MethodHandle by lazy { + handle("LIB_NAME_kne_disposeRef", FunctionDescriptor.ofVoid(JAVA_LONG)) + } + private val READ_STRING_REF_HANDLE: MethodHandle by lazy { + handle("LIB_NAME_kne_readStringRef", FunctionDescriptor.of(JAVA_INT, JAVA_LONG, ADDRESS, JAVA_INT)) + } + private val READ_BYTEARRAY_REF_HANDLE: MethodHandle by lazy { + handle("LIB_NAME_kne_readByteArrayRef", FunctionDescriptor.of(JAVA_INT, JAVA_LONG, ADDRESS, JAVA_INT)) + } + private val TAKE_OVERFLOW_HANDLE: MethodHandle by lazy { + handle("LIB_NAME_kne_takeOverflow", FunctionDescriptor.of(JAVA_LONG)) + } + + /** Reads and disposes a StableRef'd String. */ + fun readStringFromRef(handle: Long): String { + Arena.ofConfined().use { arena -> + var bufSize = STRING_BUF_SIZE + var buf = arena.allocate(bufSize.toLong()) + val len = READ_STRING_REF_HANDLE.invoke(handle, buf, bufSize) as Int + if (len >= bufSize) { + bufSize = len + 1 + buf = arena.allocate(bufSize.toLong()) + READ_STRING_REF_HANDLE.invoke(handle, buf, bufSize) + } + DISPOSE_REF_HANDLE.invoke(handle) + return buf.getString(0) + } + } + + /** Reads and disposes a StableRef'd ByteArray. */ + fun readByteArrayFromRef(handle: Long): ByteArray { + Arena.ofConfined().use { arena -> + var bufSize = STRING_BUF_SIZE + var buf = arena.allocate(bufSize.toLong()) + val len = READ_BYTEARRAY_REF_HANDLE.invoke(handle, buf, bufSize) as Int + if (len > bufSize) { + bufSize = len + buf = arena.allocate(bufSize.toLong()) + READ_BYTEARRAY_REF_HANDLE.invoke(handle, buf, bufSize) + } + DISPOSE_REF_HANDLE.invoke(handle) + return buf.asSlice(0, len.toLong()).toArray(JAVA_BYTE) + } + } + + // ── Results larger than the call buffers ───────────────────────────── + // The native bridge parks the full result in a thread-local slot instead of being called twice. + + /** StableRef handle of the result parked by the last bridge call on this thread, 0 if none. */ + fun takeOverflow(): Long = TAKE_OVERFLOW_HANDLE.invoke() as Long + + fun takeOverflowBytes(arena: Arena): MemorySegment { + val bytes = readByteArrayFromRef(takeOverflow()) + // One extra zeroed byte keeps String results NUL-terminated + return arena.allocate(bytes.size.toLong() + 1).also { MemorySegment.copy(bytes, 0, it, JAVA_BYTE, 0, bytes.size) } + } + + private val readCollHandles = java.util.concurrent.ConcurrentHashMap() + private val readMapHandles = java.util.concurrent.ConcurrentHashMap() + + fun readCollHandle(key: String): MethodHandle = readCollHandles.computeIfAbsent(key) { + handle("LIB_NAME_kne_suspend_readColl" + it, FunctionDescriptor.of(JAVA_INT, JAVA_LONG, ADDRESS, JAVA_INT)) + } + + fun readMapHandle(keyKey: String, valueKey: String): MethodHandle = readMapHandles.computeIfAbsent(keyKey + valueKey) { + handle("LIB_NAME_kne_suspend_readMap" + it, FunctionDescriptor.of(JAVA_INT, JAVA_LONG, ADDRESS, JAVA_INT, ADDRESS, JAVA_INT)) + } + + /** Reads a StableRef'd List/Set into [buf] (capacity [cap]), reallocating while the native side reports `-required`. */ + fun readColl(reader: MethodHandle, handle: Long, arena: Arena, buf: MemorySegment, cap: Int): Pair { + var segment = buf + var capacity = cap + while (true) { + val count = reader.invoke(handle, segment, capacity) as Int + if (count >= 0) return segment to count + val elementSize = segment.byteSize() / capacity + capacity = -count + segment = arena.allocate(elementSize * capacity, 8) + } + } + + /** Map counterpart of [readColl]: both buffers are grown to the reported requirement. */ + fun readMap(reader: MethodHandle, handle: Long, arena: Arena, keys: MemorySegment, keyCap: Int, values: MemorySegment, valueCap: Int): Triple { + var k = keys + var kc = keyCap + var v = values + var vc = valueCap + while (true) { + val count = reader.invoke(handle, k, kc, v, vc) as Int + if (count >= 0) return Triple(k, v, count) + val required = -count + k = arena.allocate(k.byteSize() / kc * required, 8).also { kc = required } + v = arena.allocate(v.byteSize() / vc * required, 8).also { vc = required } + } + } + + // ── Object lifecycle ───────────────────────────────────────────────── + + /** Keeps an object passed as an argument alive (not disposable) for the duration of a native call. */ + inline fun leased(lease: Lease?, block: () -> T): T { + lease?.acquire() + try { + return block() + } finally { + lease?.release() + } + } + + /** + * Disposal action for a proxy; captures only the native handle so the proxy itself stays collectable. + * Callback stubs live in a GC-managed arena: closing it here could free a stub that is still executing. + */ + fun disposer(handle: Long): () -> Unit = { + runCatching { DISPOSE_REF_HANDLE.invoke(handle) } + } + + /** + * Guards a native object: counts in-flight calls and defers disposal until the last one ends, + * so close() is safe from any thread, including from inside a callback on the same object. + */ + class Lease(private val dispose: () -> Unit) { + // Low bits: in-flight calls; CLOSED bit: close requested. + private val state = java.util.concurrent.atomic.AtomicInteger(0) + + val isClosed: Boolean get() = state.get() and CLOSED != 0 + + fun acquire() { + while (true) { + val s = state.get() + check(s and CLOSED == 0) { "Native object has been closed" } + if (state.compareAndSet(s, s + 1)) return + } + } + + fun release() { + if (state.decrementAndGet() == CLOSED) dispose() + } + + fun close() { + while (true) { + val s = state.get() + if (s and CLOSED != 0) return + if (state.compareAndSet(s, s or CLOSED)) { + if (s == 0) dispose() + return + } + } + } + + inline fun use(block: () -> T): T { + acquire() + try { + return block() + } finally { + release() + } + } + + private companion object { + const val CLOSED = 1 shl 30 + } + } +""" + + /** + * Coroutine completion plumbing: the upcall stubs are created once in the global arena and + * dispatch on a call id, so no per-call stub (and no captured continuation) outlives its call. + */ + private val COROUTINE_CALLS_SUPPORT = """ + class SuspendCall(val onResult: (Int, Long) -> Unit, val onError: (Long) -> Unit) + + private val coroutineCalls = java.util.concurrent.ConcurrentHashMap() + private val coroutineCallIds = java.util.concurrent.atomic.AtomicLong() + + fun registerCall(call: Any): Long = coroutineCallIds.incrementAndGet().also { coroutineCalls[it] = call } + + fun unregisterCall(id: Long) { + coroutineCalls.remove(id) + } + + @JvmStatic + fun _upcall_suspendCont(id: Long, hasValue: Int, value: Long) { + try { (coroutineCalls.remove(id) as? SuspendCall)?.onResult?.invoke(hasValue, value) } catch (t: Throwable) { uncaught(t) } + } + + @JvmStatic + fun _upcall_suspendExc(id: Long, msgHandle: Long) { + try { (coroutineCalls.remove(id) as? SuspendCall)?.onError?.invoke(msgHandle) } catch (t: Throwable) { uncaught(t) } + } + + private fun staticStub(name: String, type: java.lang.invoke.MethodType, descriptor: FunctionDescriptor): Long = + linker.upcallStub(java.lang.invoke.MethodHandles.lookup().findStatic(KneRuntime::class.java, name, type), descriptor, globalArena).address() + + val SUSPEND_CONT_STUB: Long by lazy { + staticStub("_upcall_suspendCont", java.lang.invoke.MethodType.methodType(Void.TYPE, Long::class.javaPrimitiveType, Int::class.javaPrimitiveType, Long::class.javaPrimitiveType), FunctionDescriptor.ofVoid(JAVA_LONG, JAVA_INT, JAVA_LONG)) + } + val SUSPEND_EXC_STUB: Long by lazy { + staticStub("_upcall_suspendExc", java.lang.invoke.MethodType.methodType(Void.TYPE, Long::class.javaPrimitiveType, Long::class.javaPrimitiveType), FunctionDescriptor.ofVoid(JAVA_LONG, JAVA_LONG)) + } + +""" + + private val FLOW_CALLS_SUPPORT = """ + + // ── Flow completion plumbing (static stubs, dispatched by call id) ─── + + class FlowCall(val onNext: (Long) -> Unit, val onError: (Long) -> Unit, val onComplete: () -> Unit) + + @JvmStatic + fun _upcall_flowNext(id: Long, value: Long) { + try { (coroutineCalls[id] as? FlowCall)?.onNext?.invoke(value) } catch (t: Throwable) { uncaught(t) } + } + + @JvmStatic + fun _upcall_flowError(id: Long, msgHandle: Long) { + try { (coroutineCalls.remove(id) as? FlowCall)?.onError?.invoke(msgHandle) } catch (t: Throwable) { uncaught(t) } + } + + @JvmStatic + fun _upcall_flowComplete(id: Long) { + try { (coroutineCalls.remove(id) as? FlowCall)?.onComplete?.invoke() } catch (t: Throwable) { uncaught(t) } + } + + private val LONG_LONG_TO_VOID = java.lang.invoke.MethodType.methodType(Void.TYPE, Long::class.javaPrimitiveType, Long::class.javaPrimitiveType) + + val FLOW_NEXT_STUB: Long by lazy { staticStub("_upcall_flowNext", LONG_LONG_TO_VOID, FunctionDescriptor.ofVoid(JAVA_LONG, JAVA_LONG)) } + val FLOW_ERROR_STUB: Long by lazy { staticStub("_upcall_flowError", LONG_LONG_TO_VOID, FunctionDescriptor.ofVoid(JAVA_LONG, JAVA_LONG)) } + val FLOW_COMPLETE_STUB: Long by lazy { + staticStub("_upcall_flowComplete", java.lang.invoke.MethodType.methodType(Void.TYPE, Long::class.javaPrimitiveType), FunctionDescriptor.ofVoid(JAVA_LONG)) + } +""" + + /** Native job handle owned by the JVM (generated when the module has suspend functions or flows). */ + private fun nativeJobSupport(hasFlow: Boolean): String = """ + + /** Serializes cancel/credit/dispose so that the native job StableRef is never used after disposal. */ + class NativeJob { + private var handle = 0L + private var cancelled = false + private var released = false + + @Synchronized fun attach(h: Long) { + if (released) { DISPOSE_REF_HANDLE.invoke(h); return } + handle = h + if (cancelled) CANCEL_JOB_HANDLE.invoke(h) + } + + @Synchronized fun cancel() { + cancelled = true + if (!released && handle != 0L) CANCEL_JOB_HANDLE.invoke(handle) + } +""" + (if (hasFlow) """ + @Synchronized fun credit() { + if (!released && handle != 0L) FLOW_CREDIT_HANDLE.invoke(handle) + } +""" else "") + """ + @Synchronized fun release() { + if (released) return + released = true + if (handle != 0L) DISPOSE_REF_HANDLE.invoke(handle) + } + } +""" + + /** Packages only available on Kotlin/Native, never copied to JVM sources. */ + private val NATIVE_ONLY_PACKAGES = listOf("kotlinx.cinterop", "platform", "kotlin.native") } /** @@ -73,15 +397,14 @@ class FfmProxyGenerator { reg(emptyList(), "int") reg(listOf("void*", "int"), "int") - // Dispose handle (used by all classes) + // Dispose handle (used by all classes), setCallbackError, flowCredit reg(listOf("long long"), null) - - // Suspend/Flow helpers - val hasSuspend = module.classes.any { c -> c.methods.any { it.isSuspend } } || module.functions.any { it.isSuspend } - val hasFlow = module.classes.any { c -> c.methods.any { it.returnType is KneType.FLOW } } || module.functions.any { it.returnType is KneType.FLOW } - if (hasSuspend || hasFlow) { - reg(listOf("long long", "void*", "int"), "int") // readStringRef - } + // takeOverflow + reg(emptyList(), "long long") + // readStringRef, readByteArrayRef, collection readers + reg(listOf("long long", "void*", "int"), "int") + // map readers + reg(listOf("long long", "void*", "int", "void*", "int"), "int") // All class constructors, methods, properties for (cls in module.classes) { @@ -226,15 +549,13 @@ class FfmProxyGenerator { val hasFlow = module.classes.any { c -> c.methods.any { it.returnType is KneType.FLOW } } || module.functions.any { it.returnType is KneType.FLOW } if (hasSuspend || hasFlow) { - // SUSPEND_CONT_DESC: (Int, Long) -> void - reg(listOf("JAVA_INT", "JAVA_LONG"), null) - // SUSPEND_EXC_DESC: (Long) -> void - reg(listOf("JAVA_LONG"), null) + // Static completion stubs, first argument is the call id + reg(listOf("JAVA_LONG", "JAVA_INT", "JAVA_LONG"), null) // suspend result + reg(listOf("JAVA_LONG", "JAVA_LONG"), null) // suspend error, flow next/error } if (hasFlow) { - // FLOW_COMPLETE_DESC: () -> void - reg(emptyList(), null) + reg(listOf("JAVA_LONG"), null) // flow complete } // Lambda callback upcalls @@ -312,7 +633,7 @@ class FfmProxyGenerator { files["KotlinNativeException.kt"] = generateException(jvmPackage) module.dataClasses.filter { !it.isCommon }.forEach { dc -> - files["${dc.simpleName}.kt"] = generateDataClassFile(dc, jvmPackage) + files["${dc.simpleName}.kt"] = generateDataClassFile(dc, module, jvmPackage) } module.classes.filter { !it.isCommon }.forEach { cls -> @@ -382,6 +703,7 @@ class FfmProxyGenerator { appendLine("import java.lang.foreign.Linker") appendLine("import java.lang.foreign.SymbolLookup") appendLine("import java.lang.foreign.MemorySegment") + appendLine("import java.lang.foreign.ValueLayout") appendLine("import java.lang.foreign.ValueLayout.*") appendLine("import java.lang.invoke.MethodHandle") appendLine("import java.nio.file.Files") @@ -398,6 +720,9 @@ class FfmProxyGenerator { appendLine(" */") appendLine("internal object KneRuntime {") appendLine() + appendLine(" const val STRING_BUF_SIZE = $STRING_BUF_SIZE") + appendLine(" const val COLLECTION_BUF_SIZE = $MAX_COLLECTION_SIZE") + appendLine() appendLine(" val linker: Linker = Linker.nativeLinker()") appendLine(" private val globalArena: Arena = Arena.global()") appendLine() @@ -484,10 +809,11 @@ class FfmProxyGenerator { appendLine(" Arena.ofConfined().use { arena ->") appendLine(" val buf = arena.allocate($ERR_BUF_SIZE.toLong())") appendLine(" GET_LAST_ERROR_HANDLE.invoke(buf, $ERR_BUF_SIZE)") - appendLine(" throw KotlinNativeException(buf.getString(0))") + appendLine(" throw exceptionFor(buf.getString(0))") appendLine(" }") appendLine(" }") appendLine(" }") + append(RUNTIME_SUPPORT.replace("LIB_NAME", libName)) // Generate upcall infrastructure for each callback signature if (callbackSignatures.isNotEmpty()) { @@ -503,105 +829,23 @@ class FfmProxyGenerator { appendLine() appendLine(" // ── Suspend function upcall stubs ────────────────────────────────────") appendLine() - appendLine(" // Continuation callback: (hasValue: Int, value: Long) -> Unit") - appendLine(" @JvmStatic") - appendLine(" fun _upcall_suspendCont(fn: Any, hasValue: Int, value: Long) {") - appendLine(" @Suppress(\"UNCHECKED_CAST\")") - appendLine(" (fn as (Int, Long) -> Unit).invoke(hasValue, value)") - appendLine(" }") - appendLine(" val SUSPEND_CONT_MH: java.lang.invoke.MethodHandle by lazy {") - appendLine(" java.lang.invoke.MethodHandles.lookup().findStatic(KneRuntime::class.java, \"_upcall_suspendCont\",") - appendLine(" java.lang.invoke.MethodType.methodType(Void.TYPE, Any::class.java, Int::class.javaPrimitiveType, Long::class.javaPrimitiveType))") - appendLine(" }") - appendLine(" val SUSPEND_CONT_DESC: FunctionDescriptor = FunctionDescriptor.ofVoid(JAVA_INT, JAVA_LONG)") - appendLine(" fun createSuspendContStub(fn: (Int, Long) -> Unit, arena: Arena): Long {") - appendLine(" return linker.upcallStub(SUSPEND_CONT_MH.bindTo(fn), SUSPEND_CONT_DESC, arena).address()") - appendLine(" }") - appendLine() - appendLine(" // Exception callback: (msgHandle: Long) -> Unit") - appendLine(" @JvmStatic") - appendLine(" fun _upcall_suspendExc(fn: Any, msgHandle: Long) {") - appendLine(" @Suppress(\"UNCHECKED_CAST\")") - appendLine(" (fn as (Long) -> Unit).invoke(msgHandle)") - appendLine(" }") - appendLine(" val SUSPEND_EXC_MH: java.lang.invoke.MethodHandle by lazy {") - appendLine(" java.lang.invoke.MethodHandles.lookup().findStatic(KneRuntime::class.java, \"_upcall_suspendExc\",") - appendLine(" java.lang.invoke.MethodType.methodType(Void.TYPE, Any::class.java, Long::class.javaPrimitiveType))") - appendLine(" }") - appendLine(" val SUSPEND_EXC_DESC: FunctionDescriptor = FunctionDescriptor.ofVoid(JAVA_LONG)") - appendLine(" fun createSuspendExcStub(fn: (Long) -> Unit, arena: Arena): Long {") - appendLine(" return linker.upcallStub(SUSPEND_EXC_MH.bindTo(fn), SUSPEND_EXC_DESC, arena).address()") - appendLine(" }") - appendLine() + append(COROUTINE_CALLS_SUPPORT) appendLine(" // Module-level handles for suspend helpers") appendLine(" val CANCEL_JOB_HANDLE: MethodHandle by lazy {") appendLine(" handle(\"${libName}_kne_cancelJob\", FunctionDescriptor.ofVoid(JAVA_LONG))") appendLine(" }") - appendLine(" val DISPOSE_REF_HANDLE: MethodHandle by lazy {") - appendLine(" handle(\"${libName}_kne_disposeRef\", FunctionDescriptor.ofVoid(JAVA_LONG))") - appendLine(" }") - appendLine(" val READ_STRING_REF_HANDLE: MethodHandle by lazy {") - appendLine(" handle(\"${libName}_kne_readStringRef\", FunctionDescriptor.of(JAVA_INT, JAVA_LONG, ADDRESS, JAVA_INT))") - appendLine(" }") - appendLine(" fun readStringFromRef(handle: Long): String {") - appendLine(" Arena.ofConfined().use { arena ->") - appendLine(" var _bufSize = $STRING_BUF_SIZE") - appendLine(" var _buf = arena.allocate(_bufSize.toLong())") - appendLine(" val _len = READ_STRING_REF_HANDLE.invoke(handle, _buf, _bufSize) as Int") - appendLine(" if (_len >= _bufSize) {") - appendLine(" _bufSize = _len + 1") - appendLine(" _buf = arena.allocate(_bufSize.toLong())") - appendLine(" READ_STRING_REF_HANDLE.invoke(handle, _buf, _bufSize)") - appendLine(" }") - appendLine(" DISPOSE_REF_HANDLE.invoke(handle)") - appendLine(" return _buf.getString(0)") - appendLine(" }") - appendLine(" }") - appendLine(" val READ_BYTEARRAY_REF_HANDLE: MethodHandle by lazy {") - appendLine(" handle(\"${libName}_kne_readByteArrayRef\", FunctionDescriptor.of(JAVA_INT, JAVA_LONG, ADDRESS, JAVA_INT))") - appendLine(" }") - appendLine(" fun readByteArrayFromRef(handle: Long): ByteArray {") - appendLine(" Arena.ofConfined().use { arena ->") - appendLine(" var _bufSize = $STRING_BUF_SIZE") - appendLine(" var _buf = arena.allocate(_bufSize.toLong())") - appendLine(" val _len = READ_BYTEARRAY_REF_HANDLE.invoke(handle, _buf, _bufSize) as Int") - appendLine(" if (_len > _bufSize) {") - appendLine(" _bufSize = _len") - appendLine(" _buf = arena.allocate(_bufSize.toLong())") - appendLine(" READ_BYTEARRAY_REF_HANDLE.invoke(handle, _buf, _bufSize)") - appendLine(" }") - appendLine(" DISPOSE_REF_HANDLE.invoke(handle)") - appendLine(" return _buf.asSlice(0, _len.toLong()).toArray(JAVA_BYTE)") - appendLine(" }") - appendLine(" }") + if (hasFlow) { + appendLine(" val FLOW_CREDIT_HANDLE: MethodHandle by lazy {") + appendLine(" handle(\"${libName}_kne_flowCredit\", FunctionDescriptor.ofVoid(JAVA_LONG))") + appendLine(" }") + } + append(nativeJobSupport(hasFlow)) } // Generate flow infrastructure (onNext reuses suspendExc stub, onComplete is new) if (hasFlow) { appendLine() - appendLine(" // ── Flow upcall stubs ────────────────────────────────────────────────") - appendLine(" // onNext: (value: Long) -> Unit — reuses SUSPEND_EXC signature") - appendLine(" fun createFlowNextStub(fn: (Long) -> Unit, arena: Arena): Long =") - appendLine(" createSuspendExcStub(fn, arena)") - appendLine() - appendLine(" // onError: (msgHandle: Long) -> Unit — reuses SUSPEND_EXC signature") - appendLine(" fun createFlowErrorStub(fn: (Long) -> Unit, arena: Arena): Long =") - appendLine(" createSuspendExcStub(fn, arena)") - appendLine() - appendLine(" // onComplete: () -> Unit") - appendLine(" @JvmStatic") - appendLine(" fun _upcall_flowComplete(fn: Any) {") - appendLine(" @Suppress(\"UNCHECKED_CAST\")") - appendLine(" (fn as () -> Unit).invoke()") - appendLine(" }") - appendLine(" val FLOW_COMPLETE_MH: java.lang.invoke.MethodHandle by lazy {") - appendLine(" java.lang.invoke.MethodHandles.lookup().findStatic(KneRuntime::class.java, \"_upcall_flowComplete\",") - appendLine(" java.lang.invoke.MethodType.methodType(Void.TYPE, Any::class.java))") - appendLine(" }") - appendLine(" val FLOW_COMPLETE_DESC: FunctionDescriptor = FunctionDescriptor.ofVoid()") - appendLine(" fun createFlowCompleteStub(fn: () -> Unit, arena: Arena): Long {") - appendLine(" return linker.upcallStub(FLOW_COMPLETE_MH.bindTo(fn), FLOW_COMPLETE_DESC, arena).address()") - appendLine(" }") + append(FLOW_CALLS_SUPPORT) } appendLine("}") @@ -663,9 +907,27 @@ class FfmProxyGenerator { val returnJvmType = upcallJvmType(sig.returnType) val returnDecl = if (sig.returnType == KneType.UNIT) "" else ": $returnJvmType" + // Exceptions never escape an upcall: the wrapper parks them and makes the native caller unwind + val defaultReturn = when (returnJvmType) { + "Unit" -> null + "Long" -> "0L" + "Double" -> "0.0" + "Float" -> "0f" + "MemorySegment" -> "MemorySegment.NULL" + else -> "0" + } + val forwardArgs = (listOf("fn") + flatParams.map { it.name }).joinToString(", ") appendLine() appendLine(" @JvmStatic") appendLine(" fun _upcall_$id($targetParams)$returnDecl {") + if (defaultReturn == null) { + appendLine(" try { _upcallBody_$id($forwardArgs) } catch (t: Throwable) { reportCallbackError(t) }") + } else { + appendLine(" return try { _upcallBody_$id($forwardArgs) } catch (t: Throwable) { reportCallbackError(t); $defaultReturn }") + } + appendLine(" }") + appendLine() + appendLine(" private fun _upcallBody_$id($targetParams)$returnDecl {") appendLine(" @Suppress(\"UNCHECKED_CAST\")") appendLine(" val _fn = fn as ${sig.jvmTypeName}") @@ -748,7 +1010,7 @@ class FfmProxyGenerator { appendLine(" MemorySegment.copy(_result, 0, _buf, JAVA_BYTE, 8, _result.size)") appendLine(" return _buf") } else if (sig.returnType == KneType.STRING) { - appendLine(" return Arena.ofAuto().allocateFrom(_fn.invoke($invokeConvertedArgs))") + appendLine(" return KneRuntime.cstr(Arena.ofAuto(), _fn.invoke($invokeConvertedArgs))") } else if (sig.returnType is KneType.ENUM) { appendLine(" return _fn.invoke($invokeConvertedArgs).ordinal") } else if (sig.returnType is KneType.OBJECT) { @@ -769,7 +1031,7 @@ class FfmProxyGenerator { KneType.BOOLEAN -> { appendLine(" _buf.set(JAVA_INT, ${offset}.toLong(), if (_result.${f.name}) 1 else 0)"); offset += 4 } KneType.SHORT -> { appendLine(" _buf.set(JAVA_SHORT, ${offset}.toLong(), _result.${f.name})"); offset += 2 } KneType.BYTE -> { appendLine(" _buf.set(JAVA_BYTE, ${offset}.toLong(), _result.${f.name})"); offset += 1 } - KneType.STRING -> { appendLine(" _buf.set(ADDRESS, ${offset}.toLong(), _arena.allocateFrom(_result.${f.name}))"); offset += 8 } + KneType.STRING -> { appendLine(" _buf.set(ADDRESS, ${offset}.toLong(), KneRuntime.cstr(_arena, _result.${f.name}))"); offset += 8 } else -> offset += 8 } } @@ -1010,8 +1272,7 @@ class FfmProxyGenerator { } if (classHasFlow) { appendLine("import kotlinx.coroutines.flow.Flow") - appendLine("import kotlinx.coroutines.flow.channelFlow") - appendLine("import kotlinx.coroutines.channels.awaitClose") + appendLine("import kotlinx.coroutines.flow.flow") } appendLine() @@ -1067,14 +1328,7 @@ class FfmProxyGenerator { appendLine("${modifier}class $n $ctorVisibility constructor($handleDecl) : $superClause {") if (isRoot) { - val disposedVisibility = if (hasHierarchy) "protected" else "private" - appendLine(" @Volatile $disposedVisibility var _disposed = false") - } - if (hasCallbacks) { - appendLine(" internal val _callbackArena: Arena = Arena.ofShared()") - } - if (classHasSuspend) { - appendLine(" private val _suspendInFlight = java.util.concurrent.atomic.AtomicInteger(0)") + appendLine(" internal val _lease = KneRuntime.Lease(KneRuntime.disposer(handle))") } appendLine() @@ -1085,9 +1339,6 @@ class FfmProxyGenerator { if (isInstantiable) { appendLine(" private val CLEANER = Cleaner.create()") } - if (companionHasCallbacks) { - appendLine(" private val _companionCallbackArena: Arena = Arena.ofShared()") - } appendLine() if (isInstantiable) { @@ -1442,17 +1693,9 @@ class FfmProxyGenerator { if (isInstantiable) { appendLine(" internal fun fromNativeHandle(h: Long): $n {") appendLine(" val obj = $n(h)") - if (hasCallbacks) { - appendLine(" val cbArena = obj._callbackArena") - if (classHasSuspend) { - appendLine(" val inFlight = obj._suspendInFlight") - appendLine(" CLEANER.register(obj) { if (!obj._disposed) { obj._disposed = true; repeat(1000) { if (inFlight.get() <= 0) return@repeat; Thread.sleep(1) }; runCatching { cbArena.close() }; runCatching { DISPOSE_HANDLE.invoke(h) } } }") - } else { - appendLine(" CLEANER.register(obj) { if (!obj._disposed) { obj._disposed = true; runCatching { cbArena.close() }; runCatching { DISPOSE_HANDLE.invoke(h) } } }") - } - } else { - appendLine(" CLEANER.register(obj) { if (!obj._disposed) { obj._disposed = true; runCatching { DISPOSE_HANDLE.invoke(h) } } }") - } + // The cleaning action must not reference obj, otherwise obj never becomes phantom reachable + appendLine(" val lease = obj._lease") + appendLine(" CLEANER.register(obj) { lease.close() }") appendLine(" return obj") appendLine(" }") } @@ -1472,21 +1715,11 @@ class FfmProxyGenerator { } cls.properties.forEach { prop -> appendPropertyProxy(prop, cls) } - // close() — idempotent, thread-safe, waits for in-flight suspend calls + // close() — idempotent and thread-safe; native disposal happens once in-flight calls end if (isRoot) { val openClose = if (cls.isOpen || cls.isAbstract || cls.isSealed) "open " else "" appendLine(" ${openClose}override fun close() {") - appendLine(" if (_disposed) return") - appendLine(" _disposed = true") - if (classHasSuspend) { - appendLine(" while (_suspendInFlight.get() > 0) { Thread.sleep(1) }") - } - if (hasCallbacks) { - appendLine(" runCatching { _callbackArena.close() }") - } - if (isInstantiable) { - appendLine(" runCatching { DISPOSE_HANDLE.invoke(handle) }") - } + appendLine(" _lease.close()") appendLine(" }") } appendLine("}") @@ -1566,13 +1799,15 @@ class FfmProxyGenerator { val returnDecl = if (fn.returnType == KneType.UNIT) "" else ": ${fn.returnType.jvmTypeName}" appendLine("fun $receiverSimpleName.${fn.name}($params)$returnDecl {") + val leased = receiverType is KneType.OBJECT + if (leased) appendLine(" _lease.use {") // Simple case: primitives only, no arena needed val needsArena = fn.returnType == KneType.STRING || fn.returnType == KneType.BYTE_ARRAY || fn.params.any { it.type == KneType.STRING } if (needsArena) { appendLine(" Arena.ofConfined().use { arena ->") fn.params.filter { it.type == KneType.STRING }.forEach { param -> - appendLine(" val ${param.name}Seg = arena.allocateFrom(${param.name})") + appendLine(" val ${param.name}Seg = KneRuntime.cstr(arena, ${param.name})") } val invokeArgs = buildList { add("this.handle") @@ -1599,7 +1834,7 @@ class FfmProxyGenerator { appendLine(" val _buf = arena.allocate(${STRING_BUF_SIZE}.toLong())") appendLine(" val _len = $handleName.invoke(this.handle${if (fn.params.isNotEmpty()) ", " + fn.params.joinToString(", ") { when (it.type) { KneType.STRING -> "${it.name}Seg"; KneType.BOOLEAN -> "if (${it.name}) 1 else 0"; else -> it.name } } else ""}, _buf, $STRING_BUF_SIZE) as Int") appendLine(" KneRuntime.checkError()") - appendLine(" return _buf.getString(0)") + appendLine(" return if (_len >= $STRING_BUF_SIZE) KneRuntime.readStringFromRef(KneRuntime.takeOverflow()) else _buf.getString(0)") } else -> { appendLine(" val _r = $handleName.invoke($invokeArgs)") @@ -1657,6 +1892,7 @@ class FfmProxyGenerator { } } } + if (leased) appendLine(" }") appendLine("}") appendLine() } @@ -1700,50 +1936,64 @@ class FfmProxyGenerator { } } - /** Generate a Flow-returning method proxy using channelFlow. */ + /** + * Generate a Flow-returning method proxy. + * The native producer only emits against credits granted as the JVM collector consumes elements, + * so the unlimited hand-off channel never holds more than the initial credit window and nothing is dropped. + */ private fun StringBuilder.appendFlowMethodProxy(fn: KneFunction, cls: KneClass, prefix: String) { val handleName = "${fn.name.uppercase()}_HANDLE" val flowType = fn.returnType as KneType.FLOW val elemType = flowType.elementType val params = fn.params.joinToString(", ") { "${it.name}: ${it.type.jvmTypeName}" } - appendLine(" fun ${fn.name}($params): Flow<${elemType.jvmTypeName}> = channelFlow {") - appendLine(" _suspendInFlight.incrementAndGet()") + appendLine(" fun ${fn.name}($params): Flow<${elemType.jvmTypeName}> = flow {") + appendLine(" _lease.acquire()") + appendLine(" val _job = KneRuntime.NativeJob()") + appendLine(" val _done = { _job.release(); _lease.release() }") + appendLine(" val _channel = kotlinx.coroutines.channels.Channel<${elemType.jvmTypeName}>(kotlinx.coroutines.channels.Channel.UNLIMITED)") - // onNext stub — decode element and send to channel - appendLine(" val _nextStub = KneRuntime.createFlowNextStub({ _value ->") - appendFlowElementDecode(" ", elemType) - appendLine(" }, _callbackArena)") - - // onError stub - appendLine(" val _errorStub = KneRuntime.createFlowErrorStub({ _msgHandle ->") - appendLine(" channel.close(KotlinNativeException(KneRuntime.readStringFromRef(_msgHandle)))") - appendLine(" }, _callbackArena)") - - // onComplete stub - appendLine(" val _completeStub = KneRuntime.createFlowCompleteStub({") - appendLine(" channel.close()") - appendLine(" _suspendInFlight.decrementAndGet()") - appendLine(" }, _callbackArena)") - - // Invoke native bridge — read jobHandle synchronously, then awaitClose - appendLine(" val _jobHandle: Long") - appendLine(" Arena.ofConfined().use { _callArena ->") - appendStringInvokeArgsAlloc(" ", fn.params) - appendLine(" val _cancelOut = _callArena.allocate(JAVA_LONG)") + // onNext stub — decode element and hand it to the collector + appendLine(" val _callId = KneRuntime.registerCall(KneRuntime.FlowCall({ _value ->") + appendLine(" with(_channel) {") + appendFlowElementDecode(" ", elemType) + appendLine(" }") + // onError — also the terminal signal after cancellation (null message) + appendLine(" }, { _msgHandle ->") + appendLine(" try {") + appendLine(" if (_msgHandle == 0L) _channel.close()") + appendLine(" else _channel.close(KneRuntime.exceptionFor(KneRuntime.readStringFromRef(_msgHandle)))") + appendLine(" } finally { _done() }") + appendLine(" }, {") + appendLine(" try { _channel.close() } finally { _done() }") + appendLine(" }))") + + appendLine(" try {") + appendLine(" Arena.ofConfined().use { _callArena ->") + appendStringInvokeArgsAlloc(" ", fn.params) + appendLine(" val _cancelOut = _callArena.allocate(JAVA_LONG)") val invokeArgs = buildList { add("handle") fn.params.forEach { p -> addAll(buildExpandedInvokeArgs(p)) } - add("_nextStub"); add("_errorStub"); add("_completeStub"); add("_cancelOut") + add("KneRuntime.FLOW_NEXT_STUB"); add("KneRuntime.FLOW_ERROR_STUB"); add("KneRuntime.FLOW_COMPLETE_STUB"); add("_callId"); add("_cancelOut") }.joinToString(", ") - appendLine(" $handleName.invoke($invokeArgs)") - appendLine(" _jobHandle = _cancelOut.get(JAVA_LONG, 0L) as Long") + appendLine(" $handleName.invoke($invokeArgs)") + appendLine(" _job.attach(_cancelOut.get(JAVA_LONG, 0L) as Long)") + appendLine(" }") + appendLine(" } catch (t: Throwable) {") + appendLine(" KneRuntime.unregisterCall(_callId)") + appendLine(" _lease.release()") + appendLine(" throw t") appendLine(" }") - appendLine(" awaitClose {") - appendLine(" KneRuntime.CANCEL_JOB_HANDLE.invoke(_jobHandle)") - appendLine(" _suspendInFlight.decrementAndGet()") + appendLine(" try {") + appendLine(" for (_element in _channel) {") + appendLine(" emit(_element)") + appendLine(" _job.credit()") + appendLine(" }") + appendLine(" } finally {") + appendLine(" _job.cancel()") appendLine(" }") appendLine(" }") appendLine() @@ -1838,15 +2088,15 @@ class FfmProxyGenerator { val key = suspendCollElemKey(innerElem) appendLine("${indent}Arena.ofConfined().use { _collArena ->") if (innerElem == KneType.STRING) { - appendLine("${indent} val _outBuf = _collArena.allocate($STRING_BUF_SIZE.toLong())") - appendLine("${indent} val _count = SUSPEND_READCOLL_${key.uppercase()}_HANDLE.invoke(_value, _outBuf, $STRING_BUF_SIZE) as Int") + appendLine("${indent} val _outBufInit = _collArena.allocate($STRING_BUF_SIZE.toLong())") + appendLine("${indent} val (_outBuf, _count) = KneRuntime.readColl(SUSPEND_READCOLL_${key.uppercase()}_HANDLE, _value, _collArena, _outBufInit, $STRING_BUF_SIZE)") appendLine("${indent} val _list = mutableListOf()") appendLine("${indent} var _off = 0L") appendLine("${indent} repeat(_count) { _list.add(_outBuf.getString(_off)); _off += _list.last().toByteArray(Charsets.UTF_8).size + 1 }") } else { val layout = KneType.collectionElementLayout(innerElem) - appendLine("${indent} val _outBuf = _collArena.allocate($layout, $MAX_COLLECTION_SIZE.toLong())") - appendLine("${indent} val _count = SUSPEND_READCOLL_${key.uppercase()}_HANDLE.invoke(_value, _outBuf, $MAX_COLLECTION_SIZE) as Int") + appendLine("${indent} val _outBufInit = _collArena.allocate($layout, $MAX_COLLECTION_SIZE.toLong())") + appendLine("${indent} val (_outBuf, _count) = KneRuntime.readColl(SUSPEND_READCOLL_${key.uppercase()}_HANDLE, _value, _collArena, _outBufInit, $MAX_COLLECTION_SIZE)") when (innerElem) { KneType.BOOLEAN -> appendLine("${indent} val _list = List(_count) { _outBuf.getAtIndex(JAVA_INT, it.toLong()) != 0 }") is KneType.ENUM -> appendLine("${indent} val _list = List(_count) { ${innerElem.simpleName}.entries[_outBuf.getAtIndex(JAVA_INT, it.toLong())] }") @@ -1874,13 +2124,13 @@ class FfmProxyGenerator { val isKeyString = elemType.keyType == KneType.STRING val isValString = elemType.valueType == KneType.STRING appendLine("${indent}Arena.ofConfined().use { _mapArena ->") - if (isKeyString) appendLine("${indent} val _keysBuf = _mapArena.allocate($STRING_BUF_SIZE.toLong())") - else appendLine("${indent} val _keysBuf = _mapArena.allocate(${KneType.collectionElementLayout(elemType.keyType)}, $MAX_COLLECTION_SIZE.toLong())") - if (isValString) appendLine("${indent} val _valsBuf = _mapArena.allocate($STRING_BUF_SIZE.toLong())") - else appendLine("${indent} val _valsBuf = _mapArena.allocate(${KneType.collectionElementLayout(elemType.valueType)}, $MAX_COLLECTION_SIZE.toLong())") + if (isKeyString) appendLine("${indent} val _keysBufInit = _mapArena.allocate($STRING_BUF_SIZE.toLong())") + else appendLine("${indent} val _keysBufInit = _mapArena.allocate(${KneType.collectionElementLayout(elemType.keyType)}, $MAX_COLLECTION_SIZE.toLong())") + if (isValString) appendLine("${indent} val _valsBufInit = _mapArena.allocate($STRING_BUF_SIZE.toLong())") + else appendLine("${indent} val _valsBufInit = _mapArena.allocate(${KneType.collectionElementLayout(elemType.valueType)}, $MAX_COLLECTION_SIZE.toLong())") val keySizeArg = if (isKeyString) "$STRING_BUF_SIZE" else "$MAX_COLLECTION_SIZE" val valSizeArg = if (isValString) "$STRING_BUF_SIZE" else "$MAX_COLLECTION_SIZE" - appendLine("${indent} val _count = SUSPEND_READMAP_${kk.uppercase()}_${vk.uppercase()}_HANDLE.invoke(_value, _keysBuf, $keySizeArg, _valsBuf, $valSizeArg) as Int") + appendLine("${indent} val (_keysBuf, _valsBuf, _count) = KneRuntime.readMap(SUSPEND_READMAP_${kk.uppercase()}_${vk.uppercase()}_HANDLE, _value, _mapArena, _keysBufInit, $keySizeArg, _valsBufInit, $valSizeArg)") if (isKeyString) { appendLine("${indent} val _keys = mutableListOf(); var _kOff = 0L") appendLine("${indent} repeat(_count) { _keys.add(_keysBuf.getString(_kOff)); _kOff += _keys.last().toByteArray(Charsets.UTF_8).size + 1 }") @@ -1905,23 +2155,25 @@ class FfmProxyGenerator { val overrideMod = if (fn.isOverride) "override " else "" val openMod = if (!fn.isOverride && (cls.isOpen || cls.isAbstract)) "open " else "" - appendLine(" ${overrideMod}${openMod}suspend fun ${fn.name}($params): $retType = suspendCancellableCoroutine { _cont ->") - appendLine(" _suspendInFlight.incrementAndGet()") + // The lease is held until the native side signals completion (exactly once, see CoroutineStart.ATOMIC) + appendLine(" ${overrideMod}${openMod}suspend fun ${fn.name}($params): $retType {") + appendLine(" _lease.acquire()") + appendLine(" return suspendCancellableCoroutine { _cont ->") + appendLine(" val _job = KneRuntime.NativeJob()") // Use the object's shared callback arena — stubs live as long as the proxy object - appendLine(" val _contStub = KneRuntime.createSuspendContStub({ _hasValue, _value ->") + appendLine(" val _callId = KneRuntime.registerCall(KneRuntime.SuspendCall({ _hasValue, _value ->") appendLine(" try {") appendSuspendResultDecode(" ", fn.returnType) - appendLine(" } finally { _suspendInFlight.decrementAndGet() }") - appendLine(" }, _callbackArena)") - - appendLine(" val _excStub = KneRuntime.createSuspendExcStub({ _msgHandle ->") + appendLine(" } finally { _job.release(); _lease.release() }") + appendLine(" }, { _msgHandle ->") appendLine(" try {") appendLine(" if (_msgHandle == 0L) _cont.cancel()") - appendLine(" else _cont.resumeWithException(KotlinNativeException(KneRuntime.readStringFromRef(_msgHandle)))") - appendLine(" } finally { _suspendInFlight.decrementAndGet() }") - appendLine(" }, _callbackArena)") + appendLine(" else _cont.resumeWithException(KneRuntime.exceptionFor(KneRuntime.readStringFromRef(_msgHandle)))") + appendLine(" } finally { _job.release(); _lease.release() }") + appendLine(" }))") - // Invoke native bridge + // Invoke native bridge; if the call itself fails no stub will ever fire + appendLine(" try {") appendLine(" Arena.ofConfined().use { _callArena ->") // Allocate string/DC/collection params @@ -1934,12 +2186,18 @@ class FfmProxyGenerator { val invokeArgs = buildList { add("handle") fn.params.forEach { p -> addAll(buildExpandedInvokeArgs(p)) } - add("_contStub"); add("_excStub"); add("_cancelOut") + add("KneRuntime.SUSPEND_CONT_STUB"); add("KneRuntime.SUSPEND_EXC_STUB"); add("_callId"); add("_cancelOut") }.joinToString(", ") appendLine(" $handleName.invoke($invokeArgs)") - appendLine(" val _jobHandle = _cancelOut.get(JAVA_LONG, 0L) as Long") - appendLine(" _cont.invokeOnCancellation { KneRuntime.CANCEL_JOB_HANDLE.invoke(_jobHandle) }") + appendLine(" _job.attach(_cancelOut.get(JAVA_LONG, 0L) as Long)") + appendLine(" }") + appendLine(" } catch (t: Throwable) {") + appendLine(" KneRuntime.unregisterCall(_callId)") + appendLine(" _lease.release()") + appendLine(" throw t") + appendLine(" }") + appendLine(" _cont.invokeOnCancellation { _job.cancel() }") appendLine(" }") appendLine(" }") appendLine() @@ -2061,15 +2319,15 @@ class FfmProxyGenerator { val key = suspendCollElemKey(elemType) appendLine("${indent}Arena.ofConfined().use { _collArena ->") if (elemType == KneType.STRING) { - appendLine("${indent} val _outBuf = _collArena.allocate($STRING_BUF_SIZE.toLong())") - appendLine("${indent} val _count = SUSPEND_READCOLL_${key.uppercase()}_HANDLE.invoke(_value, _outBuf, $STRING_BUF_SIZE) as Int") + appendLine("${indent} val _outBufInit = _collArena.allocate($STRING_BUF_SIZE.toLong())") + appendLine("${indent} val (_outBuf, _count) = KneRuntime.readColl(SUSPEND_READCOLL_${key.uppercase()}_HANDLE, _value, _collArena, _outBufInit, $STRING_BUF_SIZE)") appendLine("${indent} val _list = mutableListOf()") appendLine("${indent} var _off = 0L") appendLine("${indent} repeat(_count) { _list.add(_outBuf.getString(_off)); _off += _list.last().toByteArray(Charsets.UTF_8).size + 1 }") } else { val layout = KneType.collectionElementLayout(elemType) - appendLine("${indent} val _outBuf = _collArena.allocate($layout, $MAX_COLLECTION_SIZE.toLong())") - appendLine("${indent} val _count = SUSPEND_READCOLL_${key.uppercase()}_HANDLE.invoke(_value, _outBuf, $MAX_COLLECTION_SIZE) as Int") + appendLine("${indent} val _outBufInit = _collArena.allocate($layout, $MAX_COLLECTION_SIZE.toLong())") + appendLine("${indent} val (_outBuf, _count) = KneRuntime.readColl(SUSPEND_READCOLL_${key.uppercase()}_HANDLE, _value, _collArena, _outBufInit, $MAX_COLLECTION_SIZE)") // Decode elements when (elemType) { KneType.BOOLEAN -> @@ -2106,23 +2364,23 @@ class FfmProxyGenerator { appendLine("${indent}Arena.ofConfined().use { _mapArena ->") // Allocate key buffer if (isKeyString) { - appendLine("${indent} val _keysBuf = _mapArena.allocate($STRING_BUF_SIZE.toLong())") + appendLine("${indent} val _keysBufInit = _mapArena.allocate($STRING_BUF_SIZE.toLong())") } else { val kLayout = KneType.collectionElementLayout(type.keyType) - appendLine("${indent} val _keysBuf = _mapArena.allocate($kLayout, $MAX_COLLECTION_SIZE.toLong())") + appendLine("${indent} val _keysBufInit = _mapArena.allocate($kLayout, $MAX_COLLECTION_SIZE.toLong())") } // Allocate value buffer if (isValString) { - appendLine("${indent} val _valsBuf = _mapArena.allocate($STRING_BUF_SIZE.toLong())") + appendLine("${indent} val _valsBufInit = _mapArena.allocate($STRING_BUF_SIZE.toLong())") } else { val vLayout = KneType.collectionElementLayout(type.valueType) - appendLine("${indent} val _valsBuf = _mapArena.allocate($vLayout, $MAX_COLLECTION_SIZE.toLong())") + appendLine("${indent} val _valsBufInit = _mapArena.allocate($vLayout, $MAX_COLLECTION_SIZE.toLong())") } // Invoke reader bridge val keySizeArg = if (isKeyString) "$STRING_BUF_SIZE" else "$MAX_COLLECTION_SIZE" val valSizeArg = if (isValString) "$STRING_BUF_SIZE" else "$MAX_COLLECTION_SIZE" - appendLine("${indent} val _count = SUSPEND_READMAP_${kk.uppercase()}_${vk.uppercase()}_HANDLE.invoke(_value, _keysBuf, $keySizeArg, _valsBuf, $valSizeArg) as Int") + appendLine("${indent} val (_keysBuf, _valsBuf, _count) = KneRuntime.readMap(SUSPEND_READMAP_${kk.uppercase()}_${vk.uppercase()}_HANDLE, _value, _mapArena, _keysBufInit, $keySizeArg, _valsBufInit, $valSizeArg)") // Decode keys if (isKeyString) { @@ -2155,9 +2413,9 @@ class FfmProxyGenerator { val openMod = if (!fn.isOverride && (cls.isOpen || cls.isAbstract)) "open " else "" appendLine(" ${overrideMod}${openMod}fun ${fn.name}($params): ${fn.returnType.jvmTypeName} {") - - // Allocate callback stubs in persistent arena (survives async calls) - appendCallbackStubAlloc(" ", fn.params, "_callbackArena") + appendLine(" _lease.use {") + openCallbackArena(" ", fn.params) + appendCallbackStubAlloc(" ", fn.params, "_cbArena") val returnDc = extractDataClass(fn.returnType) val returnsNullableDc = fn.returnType is KneType.NULLABLE && fn.returnType.inner is KneType.DATA_CLASS @@ -2184,6 +2442,8 @@ class FfmProxyGenerator { appendCallAndReturn(" ", fn.returnType, handleName, invokeArgs) } + closeCallbackArena(" ", fn.params) + appendLine(" }") appendLine(" }") appendLine() } @@ -2349,13 +2609,13 @@ class FfmProxyGenerator { appendLine("${indent} Arena.ofConfined().use { _mapArena ->") val isKeyString = f.type.keyType == KneType.STRING val isValString = f.type.valueType == KneType.STRING - if (isKeyString) appendLine("${indent} val _keysBuf = _mapArena.allocate($STRING_BUF_SIZE.toLong())") - else appendLine("${indent} val _keysBuf = _mapArena.allocate(${KneType.collectionElementLayout(f.type.keyType)}, $MAX_COLLECTION_SIZE.toLong())") - if (isValString) appendLine("${indent} val _valsBuf = _mapArena.allocate($STRING_BUF_SIZE.toLong())") - else appendLine("${indent} val _valsBuf = _mapArena.allocate(${KneType.collectionElementLayout(f.type.valueType)}, $MAX_COLLECTION_SIZE.toLong())") + if (isKeyString) appendLine("${indent} val _keysBufInit = _mapArena.allocate($STRING_BUF_SIZE.toLong())") + else appendLine("${indent} val _keysBufInit = _mapArena.allocate(${KneType.collectionElementLayout(f.type.keyType)}, $MAX_COLLECTION_SIZE.toLong())") + if (isValString) appendLine("${indent} val _valsBufInit = _mapArena.allocate($STRING_BUF_SIZE.toLong())") + else appendLine("${indent} val _valsBufInit = _mapArena.allocate(${KneType.collectionElementLayout(f.type.valueType)}, $MAX_COLLECTION_SIZE.toLong())") val keySizeArg = if (isKeyString) "$STRING_BUF_SIZE" else "$MAX_COLLECTION_SIZE" val valSizeArg = if (isValString) "$STRING_BUF_SIZE" else "$MAX_COLLECTION_SIZE" - appendLine("${indent} val _count = SUSPEND_READMAP_${kk.uppercase()}_${vk.uppercase()}_HANDLE.invoke($handle, _keysBuf, $keySizeArg, _valsBuf, $valSizeArg) as Int") + appendLine("${indent} val (_keysBuf, _valsBuf, _count) = KneRuntime.readMap(SUSPEND_READMAP_${kk.uppercase()}_${vk.uppercase()}_HANDLE, $handle, _mapArena, _keysBufInit, $keySizeArg, _valsBufInit, $valSizeArg)") if (isKeyString) { appendLine("${indent} val _keys = mutableListOf()") appendLine("${indent} var _kOff = 0L") @@ -2390,8 +2650,8 @@ class FfmProxyGenerator { appendLine("${indent}val ${name}_collVal = run {") appendLine("${indent} Arena.ofConfined().use { _collArena ->") if (elemType == KneType.STRING) { - appendLine("${indent} val _outBuf = _collArena.allocate($STRING_BUF_SIZE.toLong())") - appendLine("${indent} val _count = SUSPEND_READCOLL_${key.uppercase()}_HANDLE.invoke($handle, _outBuf, $STRING_BUF_SIZE) as Int") + appendLine("${indent} val _outBufInit = _collArena.allocate($STRING_BUF_SIZE.toLong())") + appendLine("${indent} val (_outBuf, _count) = KneRuntime.readColl(SUSPEND_READCOLL_${key.uppercase()}_HANDLE, $handle, _collArena, _outBufInit, $STRING_BUF_SIZE)") appendLine("${indent} val _list = mutableListOf()") appendLine("${indent} var _off = 0L") appendLine("${indent} repeat(_count) { _list.add(_outBuf.getString(_off)); _off += _list.last().toByteArray(Charsets.UTF_8).size + 1 }") @@ -2399,8 +2659,8 @@ class FfmProxyGenerator { else appendLine("${indent} _list as List") } else { val layout = KneType.collectionElementLayout(elemType) - appendLine("${indent} val _outBuf = _collArena.allocate($layout, $MAX_COLLECTION_SIZE.toLong())") - appendLine("${indent} val _count = SUSPEND_READCOLL_${key.uppercase()}_HANDLE.invoke($handle, _outBuf, $MAX_COLLECTION_SIZE) as Int") + appendLine("${indent} val _outBufInit = _collArena.allocate($layout, $MAX_COLLECTION_SIZE.toLong())") + appendLine("${indent} val (_outBuf, _count) = KneRuntime.readColl(SUSPEND_READCOLL_${key.uppercase()}_HANDLE, $handle, _collArena, _outBufInit, $MAX_COLLECTION_SIZE)") when (elemType) { KneType.BOOLEAN -> appendLine("${indent} val _list = List(_count) { _outBuf.getAtIndex(JAVA_INT, it.toLong()) != 0 }") is KneType.ENUM -> appendLine("${indent} val _list = List(_count) { ${elemType.simpleName}.entries[_outBuf.getAtIndex(JAVA_INT, it.toLong())] }") @@ -2418,7 +2678,7 @@ class FfmProxyGenerator { val args = dc.fields.joinToString(", ") { f -> val name = "${prefix}_${f.name}" when (f.type) { - KneType.STRING -> "${f.name} = $name.getString(0)" + KneType.STRING -> "${f.name} = KneRuntime.outString($name)" KneType.BYTE_ARRAY -> "${f.name} = ${name}_baVal" KneType.BOOLEAN -> "${f.name} = $name.get(JAVA_INT, 0) != 0" is KneType.ENUM -> "${f.name} = ${f.type.simpleName}.entries[$name.get(JAVA_INT, 0)]" @@ -2509,6 +2769,7 @@ class FfmProxyGenerator { appendLine(" ${propMod}val ${prop.name}: ${prop.type.jvmTypeName}") } appendLine(" get() {") + appendLine(" _lease.use {") if (isCollProp) { // Collection property getter: read StableRef handle, deserialize, dispose val inner = prop.type.unwrapCollection() @@ -2544,16 +2805,16 @@ class FfmProxyGenerator { val key = suspendCollElemKey(inner.elementType) appendLine(" Arena.ofConfined().use { _collArena ->") if (inner.elementType == KneType.STRING) { - appendLine(" val _outBuf = _collArena.allocate($STRING_BUF_SIZE.toLong())") - appendLine(" val _count = SUSPEND_READCOLL_${key.uppercase()}_HANDLE.invoke(_handle, _outBuf, $STRING_BUF_SIZE) as Int") + appendLine(" val _outBufInit = _collArena.allocate($STRING_BUF_SIZE.toLong())") + appendLine(" val (_outBuf, _count) = KneRuntime.readColl(SUSPEND_READCOLL_${key.uppercase()}_HANDLE, _handle, _collArena, _outBufInit, $STRING_BUF_SIZE)") appendLine(" val _list = mutableListOf()") appendLine(" var _off = 0L") appendLine(" repeat(_count) { _list.add(_outBuf.getString(_off)); _off += _list.last().toByteArray(Charsets.UTF_8).size + 1 }") appendLine(" return _list") } else { val layout = KneType.collectionElementLayout(inner.elementType) - appendLine(" val _outBuf = _collArena.allocate($layout, $MAX_COLLECTION_SIZE.toLong())") - appendLine(" val _count = SUSPEND_READCOLL_${key.uppercase()}_HANDLE.invoke(_handle, _outBuf, $MAX_COLLECTION_SIZE) as Int") + appendLine(" val _outBufInit = _collArena.allocate($layout, $MAX_COLLECTION_SIZE.toLong())") + appendLine(" val (_outBuf, _count) = KneRuntime.readColl(SUSPEND_READCOLL_${key.uppercase()}_HANDLE, _handle, _collArena, _outBufInit, $MAX_COLLECTION_SIZE)") when (inner.elementType) { KneType.BOOLEAN -> appendLine(" return List(_count) { _outBuf.getAtIndex(JAVA_INT, it.toLong()) != 0 }") is KneType.ENUM -> appendLine(" return List(_count) { ${inner.elementType.simpleName}.entries[_outBuf.getAtIndex(JAVA_INT, it.toLong())] }") @@ -2568,8 +2829,8 @@ class FfmProxyGenerator { val key = suspendCollElemKey(inner.elementType) appendLine(" Arena.ofConfined().use { _collArena ->") val layout = KneType.collectionElementLayout(inner.elementType) - appendLine(" val _outBuf = _collArena.allocate($layout, $MAX_COLLECTION_SIZE.toLong())") - appendLine(" val _count = SUSPEND_READCOLL_${key.uppercase()}_HANDLE.invoke(_handle, _outBuf, $MAX_COLLECTION_SIZE) as Int") + appendLine(" val _outBufInit = _collArena.allocate($layout, $MAX_COLLECTION_SIZE.toLong())") + appendLine(" val (_outBuf, _count) = KneRuntime.readColl(SUSPEND_READCOLL_${key.uppercase()}_HANDLE, _handle, _collArena, _outBufInit, $MAX_COLLECTION_SIZE)") when (inner.elementType) { KneType.BOOLEAN -> appendLine(" return List(_count) { _outBuf.getAtIndex(JAVA_INT, it.toLong()) != 0 }.toSet()") is KneType.ENUM -> appendLine(" return List(_count) { ${inner.elementType.simpleName}.entries[_outBuf.getAtIndex(JAVA_INT, it.toLong())] }.toSet()") @@ -2583,13 +2844,13 @@ class FfmProxyGenerator { appendLine(" Arena.ofConfined().use { _mapArena ->") val isKeyString = inner.keyType == KneType.STRING val isValString = inner.valueType == KneType.STRING - if (isKeyString) appendLine(" val _keysBuf = _mapArena.allocate($STRING_BUF_SIZE.toLong())") - else appendLine(" val _keysBuf = _mapArena.allocate(${KneType.collectionElementLayout(inner.keyType)}, $MAX_COLLECTION_SIZE.toLong())") - if (isValString) appendLine(" val _valsBuf = _mapArena.allocate($STRING_BUF_SIZE.toLong())") - else appendLine(" val _valsBuf = _mapArena.allocate(${KneType.collectionElementLayout(inner.valueType)}, $MAX_COLLECTION_SIZE.toLong())") + if (isKeyString) appendLine(" val _keysBufInit = _mapArena.allocate($STRING_BUF_SIZE.toLong())") + else appendLine(" val _keysBufInit = _mapArena.allocate(${KneType.collectionElementLayout(inner.keyType)}, $MAX_COLLECTION_SIZE.toLong())") + if (isValString) appendLine(" val _valsBufInit = _mapArena.allocate($STRING_BUF_SIZE.toLong())") + else appendLine(" val _valsBufInit = _mapArena.allocate(${KneType.collectionElementLayout(inner.valueType)}, $MAX_COLLECTION_SIZE.toLong())") val keySizeArg = if (isKeyString) "$STRING_BUF_SIZE" else "$MAX_COLLECTION_SIZE" val valSizeArg = if (isValString) "$STRING_BUF_SIZE" else "$MAX_COLLECTION_SIZE" - appendLine(" val _count = SUSPEND_READMAP_${kk.uppercase()}_${vk.uppercase()}_HANDLE.invoke(_handle, _keysBuf, $keySizeArg, _valsBuf, $valSizeArg) as Int") + appendLine(" val (_keysBuf, _valsBuf, _count) = KneRuntime.readMap(SUSPEND_READMAP_${kk.uppercase()}_${vk.uppercase()}_HANDLE, _handle, _mapArena, _keysBufInit, $keySizeArg, _valsBufInit, $valSizeArg)") if (isKeyString) { appendLine(" val _keys = mutableListOf(); var _kOff = 0L") appendLine(" repeat(_count) { _keys.add(_keysBuf.getString(_kOff)); _kOff += _keys.last().toByteArray(Charsets.UTF_8).size + 1 }") @@ -2619,9 +2880,9 @@ class FfmProxyGenerator { appendLine(" Arena.ofConfined().use { arena ->") appendStringReadWithRetry(" ", getHandleName, "handle") if (prop.type is KneType.NULLABLE) { - appendLine(" return if (_len < 0) null else _buf.getString(0)") + appendLine(" return if (_len < 0) null else KneRuntime.utf8(_buf, _len)") } else { - appendLine(" return _buf.getString(0)") + appendLine(" return KneRuntime.utf8(_buf, _len)") } appendLine(" }") } else { @@ -2629,10 +2890,12 @@ class FfmProxyGenerator { } } appendLine(" }") + appendLine(" }") if (prop.mutable) { val setHandleName = "SET_${prop.name.uppercase()}_HANDLE" appendLine(" set(value) {") + appendLine(" _lease.use {") if (isCollProp) { // Collection property setter: serialize collection to StableRef, pass handle val inner = prop.type.unwrapCollection() @@ -2663,6 +2926,7 @@ class FfmProxyGenerator { appendSetterInvoke(" ", setHandleName, prop.type, "handle") } appendLine(" }") + appendLine(" }") } appendLine() } @@ -2676,7 +2940,8 @@ class FfmProxyGenerator { appendLine() appendLine(" fun ${fn.name}($params): ${fn.returnType.jvmTypeName} {") - appendCallbackStubAlloc(" ", fn.params, "_companionCallbackArena") + openCallbackArena(" ", fn.params) + appendCallbackStubAlloc(" ", fn.params, "_cbArena") val arenaNeeded = needsConfinedArena(fn.params, fn.returnType) if (arenaNeeded) { @@ -2690,6 +2955,7 @@ class FfmProxyGenerator { appendCallAndReturn(" ", fn.returnType, handleName, invokeArgs) } + closeCallbackArena(" ", fn.params) appendLine(" }") } @@ -2707,9 +2973,9 @@ class FfmProxyGenerator { appendLine(" Arena.ofConfined().use { arena ->") appendStringReadWithRetry(" ", getHandleName, "") if (prop.type is KneType.NULLABLE) { - appendLine(" return if (_len < 0) null else _buf.getString(0)") + appendLine(" return if (_len < 0) null else KneRuntime.utf8(_buf, _len)") } else { - appendLine(" return _buf.getString(0)") + appendLine(" return KneRuntime.utf8(_buf, _len)") } appendLine(" }") } else { @@ -2727,20 +2993,49 @@ class FfmProxyGenerator { // ── Data class file ─────────────────────────────────────────────────────── - private fun generateDataClassFile(dc: KneDataClass, pkg: String): String = buildString { + private fun generateDataClassFile(dc: KneDataClass, module: KneModule, pkg: String): String = buildString { appendLine("// Auto-generated by kotlin-native-export plugin. Do not modify.") appendLine("package $pkg") appendLine() + dc.source?.let { + appendSourceDecl(it, module, pkg) + return@buildString + } val fields = dc.fields.joinToString(", ") { "val ${it.name}: ${it.type.jvmTypeName}" } appendLine("data class ${dc.simpleName}($fields)") } + // ── Verbatim value types ───────────────────────────────────────────────── + + /** + * Emits an enum / data class exactly as declared on the native side. + * Imports of module packages are dropped (all JVM proxies live in [pkg]), + * as are imports of native-only packages. + */ + private fun StringBuilder.appendSourceDecl(source: KneSourceDecl, module: KneModule, pkg: String) { + val localPackages = module.packages + pkg + val imports = source.imports.filter { directive -> + val fq = directive.removePrefix("import").substringBefore(" as ").trim() + val importPkg = if (fq.endsWith(".*")) fq.removeSuffix(".*") else fq.substringBeforeLast('.', "") + importPkg !in localPackages && NATIVE_ONLY_PACKAGES.none { fq == it || fq.startsWith("$it.") } + } + if (imports.isNotEmpty()) { + imports.forEach { appendLine(it) } + appendLine() + } + appendLine(source.text) + } + // ── Enum proxy ─────────────────────────────────────────────────────────── private fun generateEnumProxy(enum: KneEnum, module: KneModule, pkg: String): String = buildString { appendLine("// Auto-generated by kotlin-native-export plugin. Do not modify.") appendLine("package $pkg") appendLine() + enum.source?.let { + appendSourceDecl(it, module, pkg) + return@buildString + } val header = if (enum.constructorParams.isEmpty()) { "enum class ${enum.simpleName} {" } else { @@ -2783,9 +3078,6 @@ class FfmProxyGenerator { val objectHasCallbacks = fns.any { fn -> fn.params.any { it.type is KneType.FUNCTION } } appendLine("object $objectName {") - if (objectHasCallbacks) { - appendLine(" private val _callbackArena: Arena = Arena.ofShared()") - } appendLine() fns.forEach { fn -> @@ -2802,7 +3094,8 @@ class FfmProxyGenerator { val params = fn.params.joinToString(", ") { "${it.name}: ${it.type.jvmTypeName}" } appendLine(" fun ${fn.name}($params): ${fn.returnType.jvmTypeName} {") - appendCallbackStubAlloc(" ", fn.params, "_callbackArena") + openCallbackArena(" ", fn.params) + appendCallbackStubAlloc(" ", fn.params, "_cbArena") val arenaNeeded = needsConfinedArena(fn.params, fn.returnType) if (arenaNeeded) { @@ -2818,6 +3111,7 @@ class FfmProxyGenerator { appendCallAndReturn(" ", fn.returnType, handleName, invokeArgs) } + closeCallbackArena(" ", fn.params) appendLine(" }") appendLine() } @@ -2862,6 +3156,7 @@ class FfmProxyGenerator { if (fn.isSuspend) { add("JAVA_LONG") // contPtr add("JAVA_LONG") // excPtr + add("JAVA_LONG") // callId add("ADDRESS") // cancelOut } // Flow functions: add onNext + onError + onComplete + cancelOut params @@ -2869,6 +3164,7 @@ class FfmProxyGenerator { add("JAVA_LONG") // nextPtr add("JAVA_LONG") // errorPtr add("JAVA_LONG") // completePtr + add("JAVA_LONG") // callId add("ADDRESS") // cancelOut } val skipReturnParams = fn.isSuspend || fn.returnType is KneType.FLOW @@ -3075,14 +3371,14 @@ class FfmProxyGenerator { private fun StringBuilder.appendStringInvokeArgsAlloc(indent: String, params: List) { params.filter { it.type == KneType.STRING }.forEach { p -> - appendLine("${indent}val ${p.name}Seg = arena.allocateFrom(${p.name})") + appendLine("${indent}val ${p.name}Seg = KneRuntime.cstr(arena, ${p.name})") } params.filter { it.type == KneType.BYTE_ARRAY }.forEach { p -> appendLine("${indent}val ${p.name}Seg = arena.allocate(${p.name}.size.toLong())") appendLine("${indent}MemorySegment.copy(${p.name}, 0, ${p.name}Seg, JAVA_BYTE, 0, ${p.name}.size)") } params.filter { it.type is KneType.NULLABLE && (it.type as KneType.NULLABLE).inner == KneType.STRING }.forEach { p -> - appendLine("${indent}val ${p.name}Seg = if (${p.name} != null) arena.allocateFrom(${p.name}) else MemorySegment.NULL") + appendLine("${indent}val ${p.name}Seg = if (${p.name} != null) KneRuntime.cstr(arena, ${p.name}) else MemorySegment.NULL") } // Allocate String fields from data class params (including nullable) params.forEach { p -> @@ -3090,9 +3386,9 @@ class FfmProxyGenerator { val isNullable = p.type is KneType.NULLABLE dc.fields.filter { it.type == KneType.STRING }.forEach { f -> if (isNullable) { - appendLine("${indent}val ${p.name}_${f.name}Seg = if (${p.name} != null) arena.allocateFrom(${p.name}.${f.name}) else MemorySegment.NULL") + appendLine("${indent}val ${p.name}_${f.name}Seg = if (${p.name} != null) KneRuntime.cstr(arena, ${p.name}.${f.name}) else MemorySegment.NULL") } else { - appendLine("${indent}val ${p.name}_${f.name}Seg = arena.allocateFrom(${p.name}.${f.name})") + appendLine("${indent}val ${p.name}_${f.name}Seg = KneRuntime.cstr(arena, ${p.name}.${f.name})") } } dc.fields.filter { it.type == KneType.BYTE_ARRAY }.forEach { f -> @@ -3124,7 +3420,7 @@ class FfmProxyGenerator { appendLine("${indent} for (_elem in _src) {") val fieldsWithPaths = buildDcFieldsWithAccessPaths(elemType, "_elem") fieldsWithPaths.filter { it.type == KneType.STRING }.forEach { ff -> - appendLine("${indent} val ${ff.segName} = arena.allocateFrom(${ff.accessExpr})") + appendLine("${indent} val ${ff.segName} = KneRuntime.cstr(arena, ${ff.accessExpr})") } val addArgs = buildList { add("_h") @@ -3243,6 +3539,29 @@ class FfmProxyGenerator { } /** Emit callback stub allocation using the persistent arena. */ + private fun List.hasCallbackParams(): Boolean = + any { it.type is KneType.FUNCTION || (it.type is KneType.NULLABLE && (it.type as KneType.NULLABLE).inner is KneType.FUNCTION) } + + /** + * Opens a per-call arena for callback upcall stubs: callbacks are valid for the duration of the call only. + * Closing it right after the call releases the stubs and everything their lambdas capture. + */ + private fun StringBuilder.openCallbackArena(indent: String, params: List) { + // Objects passed as arguments must not be disposed while the native call uses them + params.forEach { p -> + val t = p.type + if (t is KneType.OBJECT) appendLine("${indent}KneRuntime.leased(${p.name}._lease) {") + if (t is KneType.NULLABLE && t.inner is KneType.OBJECT) appendLine("${indent}KneRuntime.leased(${p.name}?._lease) {") + } + if (params.hasCallbackParams()) appendLine("${indent}Arena.ofConfined().use { _cbArena ->") + } + + private fun StringBuilder.closeCallbackArena(indent: String, params: List) { + if (params.hasCallbackParams()) appendLine("${indent}}") + val leases = params.count { val t = it.type; t is KneType.OBJECT || (t is KneType.NULLABLE && t.inner is KneType.OBJECT) } + repeat(leases) { appendLine("${indent}}") } + } + private fun StringBuilder.appendCallbackStubAlloc(indent: String, params: List, arenaExpr: String) { params.filter { it.type is KneType.FUNCTION }.forEach { p -> val fnType = p.type as KneType.FUNCTION @@ -3380,7 +3699,7 @@ class FfmProxyGenerator { val fieldsWithPaths = buildDcFieldsWithAccessPaths(dc, "_elem") // Allocate String segments fieldsWithPaths.filter { it.type == KneType.STRING }.forEach { f -> - appendLine("${indent} val ${f.segName} = arena.allocateFrom(${f.accessExpr})") + appendLine("${indent} val ${f.segName} = KneRuntime.cstr(arena, ${f.accessExpr})") } // Build ADD invoke args val addArgs = buildList { @@ -3475,10 +3794,11 @@ class FfmProxyGenerator { } when (elemType) { KneType.STRING -> { - appendLine("${indent}val _outBuf = arena.allocate($STRING_BUF_SIZE.toLong())") + appendLine("${indent}var _outBuf = arena.allocate($STRING_BUF_SIZE.toLong())") val invokeArgs = buildClassInvokeArgsExpanded(fn) + ", _outBuf, $STRING_BUF_SIZE" - appendLine("${indent}val _count = $handleName.invoke($invokeArgs) as Int") + appendLine("${indent}var _count = $handleName.invoke($invokeArgs) as Int") appendLine("${indent}KneRuntime.checkError()") + appendOverflowCollectionRead(indent, elemType, STRING_BUF_SIZE) if (nullable) appendLine("${indent}if (_count < 0) return null") appendLine("${indent}val _list = mutableListOf()") appendLine("${indent}var _off = 0L") @@ -3488,16 +3808,27 @@ class FfmProxyGenerator { } else -> { val layout = KneType.collectionElementLayout(elemType) - appendLine("${indent}val _outBuf = arena.allocate($layout, $MAX_COLLECTION_SIZE.toLong())") + appendLine("${indent}var _outBuf = arena.allocate($layout, $MAX_COLLECTION_SIZE.toLong())") val invokeArgs = buildClassInvokeArgsExpanded(fn) + ", _outBuf, $MAX_COLLECTION_SIZE" - appendLine("${indent}val _count = $handleName.invoke($invokeArgs) as Int") + appendLine("${indent}var _count = $handleName.invoke($invokeArgs) as Int") appendLine("${indent}KneRuntime.checkError()") + appendOverflowCollectionRead(indent, elemType, MAX_COLLECTION_SIZE) if (nullable) appendLine("${indent}if (_count < 0) return null") appendCollectionElementRead(indent, elemType, "_count", collType) } } } + /** Int.MIN_VALUE means the result did not fit: read the natively parked collection back by handle. */ + private fun StringBuilder.appendOverflowCollectionRead(indent: String, elemType: KneType, capacity: Int) { + val reader = "KneRuntime.readCollHandle(\"${suspendCollElemKey(elemType)}\")" + appendLine("${indent}if (_count == Int.MIN_VALUE) {") + appendLine("${indent} val _overflow = KneRuntime.readColl($reader, KneRuntime.takeOverflow(), arena, _outBuf, $capacity)") + appendLine("${indent} _outBuf = _overflow.first") + appendLine("${indent} _count = _overflow.second") + appendLine("${indent}}") + } + private fun StringBuilder.appendCollectionElementRead(indent: String, elemType: KneType, countExpr: String, collType: String) { when (elemType) { KneType.BOOLEAN -> { @@ -3539,15 +3870,15 @@ class FfmProxyGenerator { val key = suspendCollElemKey(innerElem) appendLine("${indent}Arena.ofConfined().use { _innerArena ->") if (innerElem == KneType.STRING) { - appendLine("${indent} val _iBuf = _innerArena.allocate($STRING_BUF_SIZE.toLong())") - appendLine("${indent} val _iCount = SUSPEND_READCOLL_${key.uppercase()}_HANDLE.invoke(_innerHandle, _iBuf, $STRING_BUF_SIZE) as Int") + appendLine("${indent} val _iBufInit = _innerArena.allocate($STRING_BUF_SIZE.toLong())") + appendLine("${indent} val (_iBuf, _iCount) = KneRuntime.readColl(SUSPEND_READCOLL_${key.uppercase()}_HANDLE, _innerHandle, _innerArena, _iBufInit, $STRING_BUF_SIZE)") appendLine("${indent} val _inner = mutableListOf()") appendLine("${indent} var _iOff = 0L") appendLine("${indent} repeat(_iCount) { _inner.add(_iBuf.getString(_iOff)); _iOff += _inner.last().toByteArray(Charsets.UTF_8).size + 1 }") } else { val layout = KneType.collectionElementLayout(innerElem) - appendLine("${indent} val _iBuf = _innerArena.allocate($layout, $MAX_COLLECTION_SIZE.toLong())") - appendLine("${indent} val _iCount = SUSPEND_READCOLL_${key.uppercase()}_HANDLE.invoke(_innerHandle, _iBuf, $MAX_COLLECTION_SIZE) as Int") + appendLine("${indent} val _iBufInit = _innerArena.allocate($layout, $MAX_COLLECTION_SIZE.toLong())") + appendLine("${indent} val (_iBuf, _iCount) = KneRuntime.readColl(SUSPEND_READCOLL_${key.uppercase()}_HANDLE, _innerHandle, _innerArena, _iBufInit, $MAX_COLLECTION_SIZE)") when (innerElem) { KneType.BOOLEAN -> appendLine("${indent} val _inner = List(_iCount) { _iBuf.getAtIndex(JAVA_INT, it.toLong()) != 0 }") is KneType.ENUM -> appendLine("${indent} val _inner = List(_iCount) { ${innerElem.simpleName}.entries[_iBuf.getAtIndex(JAVA_INT, it.toLong())] }") @@ -3565,13 +3896,13 @@ class FfmProxyGenerator { val isKeyString = elemType.keyType == KneType.STRING val isValString = elemType.valueType == KneType.STRING appendLine("${indent}Arena.ofConfined().use { _innerArena ->") - if (isKeyString) appendLine("${indent} val _kBuf = _innerArena.allocate($STRING_BUF_SIZE.toLong())") - else appendLine("${indent} val _kBuf = _innerArena.allocate(${KneType.collectionElementLayout(elemType.keyType)}, $MAX_COLLECTION_SIZE.toLong())") - if (isValString) appendLine("${indent} val _vBuf = _innerArena.allocate($STRING_BUF_SIZE.toLong())") - else appendLine("${indent} val _vBuf = _innerArena.allocate(${KneType.collectionElementLayout(elemType.valueType)}, $MAX_COLLECTION_SIZE.toLong())") + if (isKeyString) appendLine("${indent} val _kBufInit = _innerArena.allocate($STRING_BUF_SIZE.toLong())") + else appendLine("${indent} val _kBufInit = _innerArena.allocate(${KneType.collectionElementLayout(elemType.keyType)}, $MAX_COLLECTION_SIZE.toLong())") + if (isValString) appendLine("${indent} val _vBufInit = _innerArena.allocate($STRING_BUF_SIZE.toLong())") + else appendLine("${indent} val _vBufInit = _innerArena.allocate(${KneType.collectionElementLayout(elemType.valueType)}, $MAX_COLLECTION_SIZE.toLong())") val ksArg = if (isKeyString) "$STRING_BUF_SIZE" else "$MAX_COLLECTION_SIZE" val vsArg = if (isValString) "$STRING_BUF_SIZE" else "$MAX_COLLECTION_SIZE" - appendLine("${indent} val _iCount = SUSPEND_READMAP_${kk.uppercase()}_${vk.uppercase()}_HANDLE.invoke(_innerHandle, _kBuf, $ksArg, _vBuf, $vsArg) as Int") + appendLine("${indent} val (_kBuf, _vBuf, _iCount) = KneRuntime.readMap(SUSPEND_READMAP_${kk.uppercase()}_${vk.uppercase()}_HANDLE, _innerHandle, _innerArena, _kBufInit, $ksArg, _vBufInit, $vsArg)") if (isKeyString) { appendLine("${indent} val _ks = mutableListOf(); var _kOff = 0L") appendLine("${indent} repeat(_iCount) { _ks.add(_kBuf.getString(_kOff)); _kOff += _ks.last().toByteArray(Charsets.UTF_8).size + 1 }") @@ -3596,10 +3927,10 @@ class FfmProxyGenerator { val vLayout = KneType.collectionElementLayout(type.valueType) val isKeyString = type.keyType == KneType.STRING val isValString = type.valueType == KneType.STRING - if (isKeyString) appendLine("${indent}val _keysBuf = arena.allocate($STRING_BUF_SIZE.toLong())") - else appendLine("${indent}val _keysBuf = arena.allocate($kLayout, $MAX_COLLECTION_SIZE.toLong())") - if (isValString) appendLine("${indent}val _valuesBuf = arena.allocate($STRING_BUF_SIZE.toLong())") - else appendLine("${indent}val _valuesBuf = arena.allocate($vLayout, $MAX_COLLECTION_SIZE.toLong())") + if (isKeyString) appendLine("${indent}var _keysBuf = arena.allocate($STRING_BUF_SIZE.toLong())") + else appendLine("${indent}var _keysBuf = arena.allocate($kLayout, $MAX_COLLECTION_SIZE.toLong())") + if (isValString) appendLine("${indent}var _valuesBuf = arena.allocate($STRING_BUF_SIZE.toLong())") + else appendLine("${indent}var _valuesBuf = arena.allocate($vLayout, $MAX_COLLECTION_SIZE.toLong())") val invokeArgs = buildList { add("handle") @@ -3611,8 +3942,18 @@ class FfmProxyGenerator { add("$MAX_COLLECTION_SIZE") }.joinToString(", ") - appendLine("${indent}val _count = $handleName.invoke($invokeArgs) as Int") + appendLine("${indent}var _count = $handleName.invoke($invokeArgs) as Int") appendLine("${indent}KneRuntime.checkError()") + // Int.MIN_VALUE means the map did not fit: read the natively parked map back by handle + val reader = "KneRuntime.readMapHandle(\"${suspendCollElemKey(type.keyType)}\", \"${suspendCollElemKey(type.valueType)}\")" + val keyCap = if (isKeyString) STRING_BUF_SIZE else MAX_COLLECTION_SIZE + val valueCap = if (isValString) STRING_BUF_SIZE else MAX_COLLECTION_SIZE + appendLine("${indent}if (_count == Int.MIN_VALUE) {") + appendLine("${indent} val _overflow = KneRuntime.readMap($reader, KneRuntime.takeOverflow(), arena, _keysBuf, $keyCap, _valuesBuf, $valueCap)") + appendLine("${indent} _keysBuf = _overflow.first") + appendLine("${indent} _valuesBuf = _overflow.second") + appendLine("${indent} _count = _overflow.third") + appendLine("${indent}}") if (nullable) appendLine("${indent}if (_count < 0) return null") appendLine("${indent}val _map = mutableMapOf<${type.keyType.jvmTypeName}, ${type.valueType.jvmTypeName}>()") // Read keys @@ -3661,7 +4002,7 @@ class FfmProxyGenerator { } KneType.STRING -> { appendStringReadWithRetry(indent, handleName, invokeArgs) - appendLine("${indent}return _buf.getString(0)") + appendLine("${indent}return KneRuntime.utf8(_buf, _len)") } KneType.BYTE_ARRAY -> { appendStringReadWithRetry(indent, handleName, invokeArgs) @@ -3729,7 +4070,7 @@ class FfmProxyGenerator { add("_fnHandle") returnType.paramTypes.forEachIndexed { i, t -> when (t) { - KneType.STRING -> add("Arena.ofAuto().allocateFrom(_p$i)") + KneType.STRING -> add("KneRuntime.cstr(Arena.ofAuto(), _p$i)") KneType.BYTE_ARRAY -> { add("run { val _a = Arena.ofAuto(); val _s = _a.allocate(_p$i.size.toLong()); MemorySegment.copy(_p$i, 0, _s, JAVA_BYTE, 0, _p$i.size); _s }"); add("_p$i.size") } KneType.BOOLEAN -> add("if (_p$i) 1 else 0") is KneType.ENUM -> add("_p$i.ordinal") @@ -3797,12 +4138,8 @@ class FfmProxyGenerator { appendLine("${indent}var _buf = arena.allocate(_bufSize.toLong())") appendLine("${indent}val _len = $handleName.invoke($bufArgs) as Int") appendLine("${indent}KneRuntime.checkError()") - appendLine("${indent}if (_len >= _bufSize) {") - appendLine("${indent} _bufSize = _len + 1") - appendLine("${indent} _buf = arena.allocate(_bufSize.toLong())") - appendLine("${indent} $handleName.invoke($bufArgs)") - appendLine("${indent} KneRuntime.checkError()") - appendLine("${indent}}") + // Oversized results are parked natively; never call the function a second time (side effects) + appendLine("${indent}if (_len >= _bufSize) _buf = KneRuntime.takeOverflowBytes(arena)") } private fun StringBuilder.appendNullableCallAndReturn( @@ -3814,7 +4151,7 @@ class FfmProxyGenerator { when (type.inner) { KneType.STRING -> { appendStringReadWithRetry(indent, handleName, invokeArgs) - appendLine("${indent}return if (_len < 0) null else _buf.getString(0)") + appendLine("${indent}return if (_len < 0) null else KneRuntime.utf8(_buf, _len)") } KneType.BOOLEAN -> { appendLine("${indent}val raw = $handleName.invoke($invokeArgs) as Int") @@ -3878,7 +4215,7 @@ class FfmProxyGenerator { when (type) { KneType.STRING -> { appendLine("${indent}Arena.ofConfined().use { arena ->") - appendLine("${indent} val valueSeg = arena.allocateFrom(value)") + appendLine("${indent} val valueSeg = KneRuntime.cstr(arena, value)") appendLine("${indent} $handleName.invoke(${prefix}valueSeg)") appendLine("${indent} KneRuntime.checkError()") appendLine("${indent}}") @@ -3908,7 +4245,7 @@ class FfmProxyGenerator { when (type.inner) { KneType.STRING -> { appendLine("${indent}Arena.ofConfined().use { arena ->") - appendLine("${indent} val valueSeg = if (value != null) arena.allocateFrom(value) else MemorySegment.NULL") + appendLine("${indent} val valueSeg = if (value != null) KneRuntime.cstr(arena, value) else MemorySegment.NULL") appendLine("${indent} $handleName.invoke(${prefix}valueSeg)") appendLine("${indent} KneRuntime.checkError()") appendLine("${indent}}") diff --git a/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/nna/plugin/codegen/NativeBridgeGenerator.kt b/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/nna/plugin/codegen/NativeBridgeGenerator.kt index 5f811612..40db26c1 100644 --- a/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/nna/plugin/codegen/NativeBridgeGenerator.kt +++ b/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/nna/plugin/codegen/NativeBridgeGenerator.kt @@ -25,6 +25,11 @@ import dev.nucleusframework.nna.plugin.ir.KneType */ class NativeBridgeGenerator { + companion object { + /** Elements a native flow may emit ahead of the JVM collector (credit-based backpressure). */ + const val FLOW_INITIAL_CREDITS = 64 + } + /** Whether this type uses the output-buffer pattern for returns. */ private fun KneType.returnsViaBuffer(): Boolean = this == KneType.STRING || this == KneType.BYTE_ARRAY || @@ -71,15 +76,57 @@ class NativeBridgeGenerator { } appendLine() - // Thread-safe error state using AtomicReference (works with new memory model + foreign threads) - appendLine("private val _kneLastError = AtomicReference(null)") + // Per-thread bridge state: each FFM downcall and its follow-up queries run on the same thread + appendLine("@kotlin.native.concurrent.ThreadLocal") + appendLine("private object _kneLastError { var value: String? = null }") + appendLine() + appendLine("/** Id of a JVM callback exception parked by the upcall stub, 0 when none. */") + appendLine("@kotlin.native.concurrent.ThreadLocal") + appendLine("private object _kneCallbackError { var value: Long = 0L }") + appendLine() + appendLine("/** Collection result that did not fit the caller buffers, handed back via kne_takeOverflow. */") + appendLine("@kotlin.native.concurrent.ThreadLocal") + appendLine("private object _kneOverflow { var value: Any? = null }") + appendLine() + appendLine("/** Unwinds native code when a JVM callback threw; the message carries the parked exception id. */") + appendLine("private class KneCallbackException(id: Long) : RuntimeException(\"kne-callback-error:\$id\")") + appendLine() + appendLine("/** Writes a NUL-terminated string; if it does not fit, writes 0xFF + a StableRef handle the JVM reads back. */") + appendLine("private fun _kneWriteCString(value: String, out: CPointer?, len: Int) {") + appendLine(" if (out == null) return") + appendLine(" val bytes = value.encodeToByteArray()") + appendLine(" if (bytes.size < len) {") + appendLine(" bytes.forEachIndexed { i, b -> out[i] = b }") + appendLine(" out[bytes.size] = 0") + appendLine(" return") + appendLine(" }") + appendLine(" val ref = StableRef.create(value).asCPointer().toLong().toString().encodeToByteArray()") + appendLine(" out[0] = 0xFF.toByte()") + appendLine(" ref.forEachIndexed { i, b -> out[i + 1] = b }") + appendLine(" out[ref.size + 1] = 0") + appendLine("}") + appendLine() + appendLine("private inline fun _kneCallback(block: () -> T): T {") + appendLine(" val result = block()") + appendLine(" val id = _kneCallbackError.value") + appendLine(" if (id != 0L) {") + appendLine(" _kneCallbackError.value = 0L") + appendLine(" throw KneCallbackException(id)") + appendLine(" }") + appendLine(" return result") + appendLine("}") appendLine() + if (hasFlow) { + appendLine("/** Flow job handle: cancellation plus credit-based backpressure driven by the JVM collector. */") + appendLine("private class KneFlowJob(val job: Job, val credits: kotlinx.coroutines.channels.Channel)") + appendLine() + } appendErrorFunctions(module.libName) // Suspend/Flow helper bridges (once per module) if (hasSuspend || hasFlow) { - appendSuspendHelpers(module.libName) + appendSuspendHelpers(module.libName, hasFlow) } module.classes.filter { !it.isCommon }.forEach { cls -> appendClass(cls, module.libName) } @@ -165,6 +212,49 @@ class NativeBridgeGenerator { appendLine(" return bytes.size") appendLine("}") appendLine() + appendLine("@CName(\"${prefix}_kne_setCallbackError\")") + appendLine("fun `${prefix}_kne_setCallbackError`(id: Long) { _kneCallbackError.value = id }") + appendLine() + // Dispose a StableRef (for cleaning up result handles) + appendLine("@CName(\"${prefix}_kne_disposeRef\")") + appendLine("fun `${prefix}_kne_disposeRef`(handle: Long) {") + appendLine(" if (handle == 0L) return") + appendLine(" try { handle.toCPointer()?.asStableRef()?.dispose() } catch (_: Throwable) {}") + appendLine("}") + appendLine() + + // Read a String from a StableRef handle + appendLine("@CName(\"${prefix}_kne_readStringRef\")") + appendLine("fun `${prefix}_kne_readStringRef`(handle: Long, outBuf: CPointer?, outLen: Int): Int {") + appendLine(" val str = handle.toCPointer()!!.asStableRef().get()") + appendLine(" val bytes = str.encodeToByteArray()") + appendLine(" val writeLen = minOf(bytes.size, outLen - 1)") + appendLine(" bytes.forEachIndexed { i, b -> if (i < writeLen) outBuf?.set(i, b) }") + appendLine(" outBuf?.set(writeLen, 0)") + appendLine(" return bytes.size") + appendLine("}") + appendLine() + + // Read a ByteArray from a StableRef handle + appendLine("@CName(\"${prefix}_kne_readByteArrayRef\")") + appendLine("fun `${prefix}_kne_readByteArrayRef`(handle: Long, outBuf: CPointer?, outLen: Int): Int {") + appendLine(" val bytes = when (val value = handle.toCPointer()!!.asStableRef().get()) {") + appendLine(" is String -> value.encodeToByteArray()") + appendLine(" else -> value as ByteArray") + appendLine(" }") + appendLine(" val writeLen = minOf(bytes.size, outLen)") + appendLine(" bytes.forEachIndexed { i, b -> if (i < writeLen) outBuf?.set(i, b) }") + appendLine(" return bytes.size") + appendLine("}") + appendLine() + + appendLine("@CName(\"${prefix}_kne_takeOverflow\")") + appendLine("fun `${prefix}_kne_takeOverflow`(): Long {") + appendLine(" val value = _kneOverflow.value ?: return 0L") + appendLine(" _kneOverflow.value = null") + appendLine(" return StableRef.create(value).asCPointer().toLong()") + appendLine("}") + appendLine() } // ── Try/catch helpers ──────────────────────────────────────────────────── @@ -438,12 +528,7 @@ class NativeBridgeGenerator { val outName = "${prefix}_${f.name}" val valueExpr = "$objExpr.${f.name}" when (f.type) { - KneType.STRING -> { - appendLine(" val ${outName}_bytes = $valueExpr.encodeToByteArray()") - appendLine(" val ${outName}_writeLen = minOf(${outName}_bytes.size, ${outName}_len - 1)") - appendLine(" ${outName}_bytes.forEachIndexed { i, b -> if (i < ${outName}_writeLen) $outName?.set(i, b) }") - appendLine(" $outName?.set(${outName}_writeLen, 0)") - } + KneType.STRING -> appendLine(" _kneWriteCString($valueExpr, $outName, ${outName}_len)") KneType.BOOLEAN -> appendLine(" $outName!![0] = if ($valueExpr) 1 else 0") is KneType.ENUM -> appendLine(" $outName!![0] = $valueExpr.ordinal") is KneType.OBJECT -> appendLine(" $outName!![0] = StableRef.create($valueExpr).asCPointer().toLong()") @@ -521,58 +606,39 @@ class NativeBridgeGenerator { } } - private fun StringBuilder.appendCollectionElementsReturn(listExpr: String, elemType: KneType) { - when (elemType) { - KneType.STRING -> { - appendLine(" var _offset = 0") - appendLine(" for (_s in $listExpr) {") - appendLine(" val _bytes = _s.encodeToByteArray()") - appendLine(" if (_offset + _bytes.size + 1 > outBufLen) break") - appendLine(" _bytes.forEachIndexed { i, b -> outBuf?.set(_offset + i, b) }") - appendLine(" outBuf?.set(_offset + _bytes.size, 0)") - appendLine(" _offset += _bytes.size + 1") - appendLine(" }") - appendLine(" return $listExpr.size") - } - KneType.BOOLEAN -> { - appendLine(" val _writeLen = minOf($listExpr.size, outLen)") - appendLine(" for (i in 0 until _writeLen) outBuf!![i] = if ($listExpr[i]) 1 else 0") - appendLine(" return $listExpr.size") - } - is KneType.ENUM -> { - appendLine(" val _writeLen = minOf($listExpr.size, outLen)") - appendLine(" for (i in 0 until _writeLen) outBuf!![i] = $listExpr[i].ordinal") - appendLine(" return $listExpr.size") - } - is KneType.OBJECT -> { - appendLine(" val _writeLen = minOf($listExpr.size, outLen)") - appendLine(" for (i in 0 until _writeLen) outBuf!![i] = StableRef.create($listExpr[i]).asCPointer().toLong()") - appendLine(" return $listExpr.size") - } - KneType.BYTE_ARRAY -> { - // ByteArray elements: wrap each as StableRef handle - appendLine(" val _writeLen = minOf($listExpr.size, outLen)") - appendLine(" for (i in 0 until _writeLen) outBuf!![i] = StableRef.create($listExpr[i]).asCPointer().toLong()") - appendLine(" return $listExpr.size") - } - is KneType.LIST, is KneType.SET, is KneType.MAP -> { - // Nested collection: wrap each inner collection as StableRef handle - appendLine(" val _writeLen = minOf($listExpr.size, outLen)") - appendLine(" for (i in 0 until _writeLen) outBuf!![i] = StableRef.create($listExpr[i] as Any).asCPointer().toLong()") - appendLine(" return $listExpr.size") - } - else -> { - appendLine(" val _writeLen = minOf($listExpr.size, outLen)") - appendLine(" for (i in 0 until _writeLen) outBuf!![i] = $listExpr[i]") - appendLine(" return $listExpr.size") - } + private fun StringBuilder.appendCollectionElementsReturn(listExpr: String, elemType: KneType, overflowValue: String = listExpr) { + // Results that do not fit the caller buffer are parked for kne_takeOverflow (signalled by Int.MIN_VALUE) + if (elemType == KneType.STRING) { + appendLine(" val _encoded = $listExpr.map { it.encodeToByteArray() }") + appendLine(" if (_encoded.sumOf { it.size + 1 } > outBufLen) { _kneOverflow.value = $overflowValue; return Int.MIN_VALUE }") + appendLine(" var _offset = 0") + appendLine(" for (_bytes in _encoded) {") + appendLine(" _bytes.forEachIndexed { i, b -> outBuf?.set(_offset + i, b) }") + appendLine(" outBuf?.set(_offset + _bytes.size, 0)") + appendLine(" _offset += _bytes.size + 1") + appendLine(" }") + appendLine(" return $listExpr.size") + return + } + appendLine(" if ($listExpr.size > outLen) { _kneOverflow.value = $overflowValue; return Int.MIN_VALUE }") + val writeExpr = when (elemType) { + KneType.BOOLEAN -> "if ($listExpr[i]) 1 else 0" + is KneType.ENUM -> "$listExpr[i].ordinal" + is KneType.OBJECT, KneType.BYTE_ARRAY -> "StableRef.create($listExpr[i]).asCPointer().toLong()" + is KneType.LIST, is KneType.SET, is KneType.MAP -> "StableRef.create($listExpr[i] as Any).asCPointer().toLong()" + else -> "$listExpr[i]" } + appendLine(" for (i in $listExpr.indices) outBuf!![i] = $writeExpr") + appendLine(" return $listExpr.size") } private fun StringBuilder.appendMapReturn(type: KneType.MAP) { appendLine(" val _keys = _result.keys.toList()") appendLine(" val _values = _result.values.toList()") - appendLine(" val _writeLen = minOf(_result.size, outLen)") + val keyFits = if (type.keyType == KneType.STRING) "_keys.sumOf { it.encodeToByteArray().size + 1 } <= outKeysLen" else "true" + val valFits = if (type.valueType == KneType.STRING) "_values.sumOf { it.encodeToByteArray().size + 1 } <= outValuesLen" else "true" + appendLine(" if (_result.size > outLen || !($keyFits) || !($valFits)) { _kneOverflow.value = _result; return Int.MIN_VALUE }") + appendLine(" val _writeLen = _result.size") // Write keys appendMapArrayWrite("_keys", type.keyType, "outKeys", if (type.keyType == KneType.STRING) "outKeysLen" else null) // Write values @@ -1252,23 +1318,23 @@ class NativeBridgeGenerator { }.joinToString(", ") if (fnType.returnType == KneType.UNIT) { - appendLine(" _fnPtr.invoke($invokeArgs)") + appendLine(" _kneCallback { _fnPtr.invoke($invokeArgs) }") } else if (fnType.returnType == KneType.BYTE_ARRAY) { // Callback returns ByteArray as packed buffer: [size:Int32][pad:4][data...] - appendLine(" val _retPtr = _fnPtr.invoke($invokeArgs)!!") + appendLine(" val _retPtr = _kneCallback { _fnPtr.invoke($invokeArgs) }!!") appendLine(" val _baSize = _retPtr.reinterpret().pointed.value") appendLine(" ByteArray(_baSize) { (_retPtr + 8 + it)!!.pointed.value }") } else if (fnType.returnType == KneType.BOOLEAN) { - appendLine(" _fnPtr.invoke($invokeArgs) != 0") + appendLine(" _kneCallback { _fnPtr.invoke($invokeArgs) } != 0") } else if (fnType.returnType == KneType.STRING) { - appendLine(" _fnPtr.invoke($invokeArgs)?.toKString() ?: \"\"") + appendLine(" _kneCallback { _fnPtr.invoke($invokeArgs) }?.toKString() ?: \"\"") } else if (fnType.returnType is KneType.ENUM) { - appendLine(" ${fnType.returnType.fqName}.entries[_fnPtr.invoke($invokeArgs)]") + appendLine(" ${fnType.returnType.fqName}.entries[_kneCallback { _fnPtr.invoke($invokeArgs) }]") } else if (fnType.returnType is KneType.OBJECT) { - appendLine(" _fnPtr.invoke($invokeArgs).toCPointer()!!.asStableRef<${fnType.returnType.fqName}>().get()") + appendLine(" _kneCallback { _fnPtr.invoke($invokeArgs) }.toCPointer()!!.asStableRef<${fnType.returnType.fqName}>().get()") } else if (fnType.returnType is KneType.DATA_CLASS) { val dc = fnType.returnType as KneType.DATA_CLASS - appendLine(" val _retPtr = _fnPtr.invoke($invokeArgs)!!") + appendLine(" val _retPtr = _kneCallback { _fnPtr.invoke($invokeArgs) }!!") var offset = 0 val fieldArgs = dc.fields.joinToString(", ") { f -> val expr = when (f.type) { @@ -1287,16 +1353,16 @@ class NativeBridgeGenerator { appendLine(" ${dc.fqName}($fieldArgs)") } else if (fnType.returnType is KneType.LIST || fnType.returnType is KneType.SET) { val elemType = when (fnType.returnType) { is KneType.LIST -> fnType.returnType.elementType; is KneType.SET -> (fnType.returnType as KneType.SET).elementType; else -> KneType.INT } - appendLine(" val _retPtr = _fnPtr.invoke($invokeArgs)!!") + appendLine(" val _retPtr = _kneCallback { _fnPtr.invoke($invokeArgs) }!!") appendLine(" val _count = _retPtr.reinterpret().pointed.value") appendCollectionReturnFromPackedBuffer(elemType, fnType.returnType is KneType.SET) } else if (fnType.returnType is KneType.MAP) { val mapType = fnType.returnType as KneType.MAP - appendLine(" val _retPtr = _fnPtr.invoke($invokeArgs)!!") + appendLine(" val _retPtr = _kneCallback { _fnPtr.invoke($invokeArgs) }!!") appendLine(" val _count = _retPtr.reinterpret().pointed.value") appendMapReturnFromPackedBuffer(mapType) } else { - appendLine(" _fnPtr.invoke($invokeArgs)") + appendLine(" _kneCallback { _fnPtr.invoke($invokeArgs) }") } if (needsMemScoped) { appendLine(" }") @@ -1477,6 +1543,7 @@ class NativeBridgeGenerator { KneType.STRING -> { appendLine(" if (_result == null) return -1") appendLine(" val bytes = _result.encodeToByteArray()") + appendLine(" if (bytes.size >= outLen) _kneOverflow.value = _result") appendLine(" val writeLen = minOf(bytes.size, outLen - 1)") appendLine(" bytes.forEachIndexed { i, b -> if (i < writeLen) outBuf?.set(i, b) }") appendLine(" outBuf?.set(writeLen, 0)") @@ -1528,6 +1595,7 @@ class NativeBridgeGenerator { private fun StringBuilder.appendStringReturn(expr: String) { appendLine(" val result = $expr") appendLine(" val bytes = result.encodeToByteArray()") + appendLine(" if (bytes.size >= outLen) _kneOverflow.value = result") appendLine(" val writeLen = minOf(bytes.size, outLen - 1)") appendLine(" bytes.forEachIndexed { i, b -> if (i < writeLen) outBuf?.set(i, b) }") appendLine(" outBuf?.set(writeLen, 0)") @@ -1536,6 +1604,7 @@ class NativeBridgeGenerator { private fun StringBuilder.appendByteArrayReturn(expr: String) { appendLine(" val result = $expr") + appendLine(" if (result.size >= outLen) _kneOverflow.value = result") appendLine(" val writeLen = minOf(result.size, outLen)") appendLine(" result.forEachIndexed { i, b -> if (i < writeLen) outBuf?.set(i, b) }") appendLine(" return result.size") @@ -1711,8 +1780,8 @@ class NativeBridgeGenerator { /** Collect all collection types used as suspend function return types. */ private fun collectSuspendCollectionTypes(module: KneModule): Set { val result = mutableSetOf() + // Direct returns need readers too: results overflowing the caller buffer are read back by handle fun scan(fn: KneFunction) { - if (!fn.isSuspend) return val rt = when (val t = fn.returnType) { is KneType.NULLABLE -> t.inner else -> t @@ -1973,168 +2042,92 @@ class NativeBridgeGenerator { } } - /** Generate a single suspend collection reader bridge for a given element key. */ - private fun StringBuilder.appendSuspendCollReaderForKey(prefix: String, key: String) { - val symbolName = "${prefix}_kne_suspend_readColl$key" - when (key) { - "String" -> { - appendLine("@CName(\"$symbolName\")") - appendLine("fun `$symbolName`(handle: Long, outBuf: CPointer?, outBufLen: Int): Int {") - appendLine(" val _coll = handle.toCPointer()!!.asStableRef().get()") - appendLine(" val _list = when (_coll) { is List<*> -> _coll; is Set<*> -> _coll.toList(); else -> emptyList() }") - appendLine(" var _offset = 0") - appendLine(" for (_s in _list) {") - appendLine(" val _bytes = (_s as String).encodeToByteArray()") - appendLine(" if (_offset + _bytes.size + 1 > outBufLen) break") - appendLine(" _bytes.forEachIndexed { i, b -> outBuf?.set(_offset + i, b) }") - appendLine(" outBuf?.set(_offset + _bytes.size, 0)") - appendLine(" _offset += _bytes.size + 1") - appendLine(" }") - appendLine(" handle.toCPointer()!!.asStableRef().dispose()") - appendLine(" return _list.size") - appendLine("}") - appendLine() - } - "ObjHandle" -> { - appendLine("@CName(\"$symbolName\")") - appendLine("fun `$symbolName`(handle: Long, outBuf: CPointer?, outLen: Int): Int {") - appendLine(" val _coll = handle.toCPointer()!!.asStableRef().get()") - appendLine(" val _list = when (_coll) { is List<*> -> _coll; is Set<*> -> _coll.toList(); else -> emptyList() }") - appendLine(" val _writeLen = minOf(_list.size, outLen)") - appendLine(" for (i in 0 until _writeLen) outBuf!![i] = StableRef.create(_list[i]!!).asCPointer().toLong()") - appendLine(" handle.toCPointer()!!.asStableRef().dispose()") - appendLine(" return _list.size") - appendLine("}") - appendLine() - } - "ByteArray" -> { - // Each ByteArray element wrapped as StableRef handle (Long) - appendLine("@CName(\"$symbolName\")") - appendLine("fun `$symbolName`(handle: Long, outBuf: CPointer?, outLen: Int): Int {") - appendLine(" val _coll = handle.toCPointer()!!.asStableRef().get()") - appendLine(" val _list = when (_coll) { is List<*> -> _coll; is Set<*> -> _coll.toList(); else -> emptyList() }") - appendLine(" val _writeLen = minOf(_list.size, outLen)") - appendLine(" for (i in 0 until _writeLen) outBuf!![i] = StableRef.create(_list[i] as ByteArray).asCPointer().toLong()") - appendLine(" handle.toCPointer()!!.asStableRef().dispose()") - appendLine(" return _list.size") - appendLine("}") - appendLine() - } - "NestedColl" -> { - // Each nested collection element wrapped as StableRef handle (Long) - appendLine("@CName(\"$symbolName\")") - appendLine("fun `$symbolName`(handle: Long, outBuf: CPointer?, outLen: Int): Int {") - appendLine(" val _coll = handle.toCPointer()!!.asStableRef().get()") - appendLine(" val _list = when (_coll) { is List<*> -> _coll; is Set<*> -> _coll.toList(); else -> emptyList() }") - appendLine(" val _writeLen = minOf(_list.size, outLen)") - appendLine(" for (i in 0 until _writeLen) outBuf!![i] = StableRef.create(_list[i] as Any).asCPointer().toLong()") - appendLine(" handle.toCPointer()!!.asStableRef().dispose()") - appendLine(" return _list.size") - appendLine("}") - appendLine() - } - else -> { - // Int, Long, Double, Float, Short, Byte, Boolean, Enum — all use the same pattern - val nativeType = when (key) { - "Long" -> "LongVar" - "Double" -> "DoubleVar" - "Float" -> "FloatVar" - else -> "IntVar" // Int, Short, Byte, Boolean, Enum - } - appendLine("@CName(\"$symbolName\")") - appendLine("fun `$symbolName`(handle: Long, outBuf: CPointer<$nativeType>?, outLen: Int): Int {") - appendLine(" val _coll = handle.toCPointer()!!.asStableRef().get()") - appendLine(" val _list = when (_coll) { is List<*> -> _coll; is Set<*> -> _coll.toList(); else -> emptyList() }") - appendLine(" val _writeLen = minOf(_list.size, outLen)") - val writeExpr = when (key) { - "Boolean" -> "if (_list[i] as Boolean) 1 else 0" - "Enum" -> "(_list[i] as Enum<*>).ordinal" - "Short" -> "(_list[i] as Short).toInt()" - "Byte" -> "(_list[i] as Byte).toInt()" - "Long" -> "_list[i] as Long" - "Double" -> "_list[i] as Double" - "Float" -> "_list[i] as Float" - else -> "_list[i] as Int" - } - appendLine(" for (i in 0 until _writeLen) outBuf!![i] = $writeExpr") - appendLine(" handle.toCPointer()!!.asStableRef().dispose()") - appendLine(" return _list.size") - appendLine("}") - appendLine() - } + /** Native element var type written by handle readers for a collection element key. */ + private fun readerVarType(key: String): String = when (key) { + "Long", "ObjHandle", "ByteArray", "NestedColl" -> "LongVar" + "Double" -> "DoubleVar" + "Float" -> "FloatVar" + "Short" -> "ShortVar" + "Byte" -> "ByteVar" + else -> "IntVar" // Int, Boolean, Enum + } + + /** Expression encoding element [expr] (typed Any?) for a collection element key. */ + private fun readerWriteExpr(key: String, expr: String): String = when (key) { + "Boolean" -> "if ($expr as Boolean) 1 else 0" + "Enum" -> "($expr as Enum<*>).ordinal" + "Short" -> "$expr as Short" + "Byte" -> "$expr as Byte" + "Long" -> "$expr as Long" + "Double" -> "$expr as Double" + "Float" -> "$expr as Float" + "ObjHandle", "ByteArray", "NestedColl" -> "StableRef.create($expr!!).asCPointer().toLong()" + else -> "$expr as Int" + } + + /** + * Emits code writing [listExpr] into [buf] of capacity [cap] (bytes for strings, elements otherwise). + * When it does not fit, returns `-required` without consuming anything so that the caller can retry. + */ + private fun StringBuilder.appendReaderWrite(key: String, listExpr: String, buf: String, cap: String, tag: String) { + if (key == "String") { + appendLine(" val _enc$tag = $listExpr.map { (it as String).encodeToByteArray() }") + appendLine(" val _req$tag = _enc$tag.sumOf { it.size + 1 }") + appendLine(" if (_req$tag > $cap) return -_req$tag") + } else { + appendLine(" if ($listExpr.size > $cap) return -$listExpr.size") } } - /** Generate a suspend map reader bridge for a given (keyKey, valueKey) combination. */ - private fun StringBuilder.appendSuspendMapReader(prefix: String, keyKey: String, valKey: String) { - val symbolName = "${prefix}_kne_suspend_readMap${keyKey}${valKey}" - - // Determine native pointer types for keys and values - val keyNativeType = if (keyKey == "String") "ByteVar" else "IntVar" - val valNativeType = when (valKey) { - "String" -> "ByteVar" - "Long" -> "LongVar" - "Double" -> "DoubleVar" - "Float" -> "FloatVar" - else -> "IntVar" - } - - val keyParamStr = if (keyKey == "String") { - "outKeys: CPointer?, outKeysLen: Int" - } else { - "outKeys: CPointer<$keyNativeType>?, outKeysMaxLen: Int" - } - val valParamStr = if (valKey == "String") { - "outVals: CPointer?, outValsLen: Int" + private fun StringBuilder.appendReaderFill(key: String, listExpr: String, buf: String, tag: String) { + if (key == "String") { + appendLine(" var _off$tag = 0") + appendLine(" for (_b in _enc$tag) {") + appendLine(" _b.forEachIndexed { i, b -> $buf!![_off$tag + i] = b }") + appendLine(" $buf!![_off$tag + _b.size] = 0") + appendLine(" _off$tag += _b.size + 1") + appendLine(" }") } else { - "outVals: CPointer<$valNativeType>?, outValsMaxLen: Int" + appendLine(" for (i in $listExpr.indices) $buf!![i] = ${readerWriteExpr(key, "$listExpr[i]")}") } + } + /** Generate a single collection reader bridge (reads a StableRef'd List/Set) for a given element key. */ + private fun StringBuilder.appendSuspendCollReaderForKey(prefix: String, key: String) { + val symbolName = "${prefix}_kne_suspend_readColl$key" + val bufType = if (key == "String") "ByteVar" else readerVarType(key) appendLine("@CName(\"$symbolName\")") - appendLine("fun `$symbolName`(handle: Long, $keyParamStr, $valParamStr): Int {") - appendLine(" val _map = handle.toCPointer()!!.asStableRef().get() as Map<*, *>") + appendLine("fun `$symbolName`(handle: Long, outBuf: CPointer<$bufType>?, outLen: Int): Int {") + appendLine(" val _ref = handle.toCPointer()!!.asStableRef()") + appendLine(" val _list = when (val _coll = _ref.get()) { is List<*> -> _coll; is Set<*> -> _coll.toList(); else -> emptyList() }") + appendReaderWrite(key, "_list", "outBuf", "outLen", "") + appendReaderFill(key, "_list", "outBuf", "") + appendLine(" _ref.dispose()") + appendLine(" return _list.size") + appendLine("}") + appendLine() + } + + /** Generate a map reader bridge (reads a StableRef'd Map) for a given (keyKey, valueKey) combination. */ + private fun StringBuilder.appendSuspendMapReader(prefix: String, keyKey: String, valKey: String) { + val symbolName = "${prefix}_kne_suspend_readMap${keyKey}${valKey}" + val keyBufType = if (keyKey == "String") "ByteVar" else readerVarType(keyKey) + val valBufType = if (valKey == "String") "ByteVar" else readerVarType(valKey) + appendLine("@CName(\"$symbolName\")") + appendLine("fun `$symbolName`(handle: Long, outKeys: CPointer<$keyBufType>?, outKeysLen: Int, outVals: CPointer<$valBufType>?, outValsLen: Int): Int {") + appendLine(" val _ref = handle.toCPointer()!!.asStableRef()") + appendLine(" val _map = _ref.get() as Map<*, *>") appendLine(" val _keys = _map.keys.toList()") appendLine(" val _vals = _map.values.toList()") - - // Write keys - if (keyKey == "String") { - appendLine(" var _kOff = 0") - appendLine(" for (_k in _keys) {") - appendLine(" val _bytes = (_k as String).encodeToByteArray()") - appendLine(" if (_kOff + _bytes.size + 1 > outKeysLen) break") - appendLine(" _bytes.forEachIndexed { i, b -> outKeys?.set(_kOff + i, b) }") - appendLine(" outKeys?.set(_kOff + _bytes.size, 0)") - appendLine(" _kOff += _bytes.size + 1") - appendLine(" }") - } else { - appendLine(" val _kWriteLen = minOf(_keys.size, outKeysMaxLen)") - appendLine(" for (i in 0 until _kWriteLen) outKeys!![i] = _keys[i] as Int") - } - - // Write values - if (valKey == "String") { - appendLine(" var _vOff = 0") - appendLine(" for (_v in _vals) {") - appendLine(" val _bytes = (_v as String).encodeToByteArray()") - appendLine(" if (_vOff + _bytes.size + 1 > outValsLen) break") - appendLine(" _bytes.forEachIndexed { i, b -> outVals?.set(_vOff + i, b) }") - appendLine(" outVals?.set(_vOff + _bytes.size, 0)") - appendLine(" _vOff += _bytes.size + 1") - appendLine(" }") - } else { - val valWriteExpr = when (valKey) { - "Long" -> "_vals[i] as Long" - "Double" -> "_vals[i] as Double" - "Float" -> "_vals[i] as Float" - "Enum" -> "(_vals[i] as Enum<*>).ordinal" - "Boolean" -> "if (_vals[i] as Boolean) 1 else 0" - else -> "_vals[i] as Int" - } - appendLine(" val _vWriteLen = minOf(_vals.size, outValsMaxLen)") - appendLine(" for (i in 0 until _vWriteLen) outVals!![i] = $valWriteExpr") - } - - appendLine(" handle.toCPointer()!!.asStableRef().dispose()") + // Capacity checks never return early with partial writes: compute both requirements first + appendLine(" val _kReq = ${if (keyKey == "String") "_keys.sumOf { (it as String).encodeToByteArray().size + 1 }" else "_keys.size"}") + appendLine(" val _vReq = ${if (valKey == "String") "_vals.sumOf { (it as String).encodeToByteArray().size + 1 }" else "_vals.size"}") + appendLine(" if (_kReq > outKeysLen || _vReq > outValsLen) return -maxOf(_kReq, _vReq)") + if (keyKey == "String") appendLine(" val _encK = _keys.map { (it as String).encodeToByteArray() }") + if (valKey == "String") appendLine(" val _encV = _vals.map { (it as String).encodeToByteArray() }") + appendReaderFill(keyKey, "_keys", "outKeys", "K") + appendReaderFill(valKey, "_vals", "outVals", "V") + appendLine(" _ref.dispose()") appendLine(" return _map.size") appendLine("}") appendLine() @@ -2143,51 +2136,37 @@ class NativeBridgeGenerator { // ── Suspend function support ──────────────────────────────────────────── /** Generate module-level helper bridges for suspend functions. */ - private fun StringBuilder.appendSuspendHelpers(prefix: String) { + private fun StringBuilder.appendSuspendHelpers(prefix: String, hasFlow: Boolean) { // Cancel a native Job from JVM appendLine("@CName(\"${prefix}_kne_cancelJob\")") appendLine("fun `${prefix}_kne_cancelJob`(jobHandle: Long) {") appendLine(" if (jobHandle == 0L) return") - appendLine(" try { jobHandle.toCPointer()!!.asStableRef().get().cancel() } catch (_: Throwable) {}") - appendLine("}") - appendLine() - - // Dispose a StableRef (for cleaning up result handles) - appendLine("@CName(\"${prefix}_kne_disposeRef\")") - appendLine("fun `${prefix}_kne_disposeRef`(handle: Long) {") - appendLine(" if (handle == 0L) return") - appendLine(" try { handle.toCPointer()?.asStableRef()?.dispose() } catch (_: Throwable) {}") - appendLine("}") - appendLine() - - // Read a String from a StableRef handle - appendLine("@CName(\"${prefix}_kne_readStringRef\")") - appendLine("fun `${prefix}_kne_readStringRef`(handle: Long, outBuf: CPointer?, outLen: Int): Int {") - appendLine(" val str = handle.toCPointer()!!.asStableRef().get()") - appendLine(" val bytes = str.encodeToByteArray()") - appendLine(" val writeLen = minOf(bytes.size, outLen - 1)") - appendLine(" bytes.forEachIndexed { i, b -> if (i < writeLen) outBuf?.set(i, b) }") - appendLine(" outBuf?.set(writeLen, 0)") - appendLine(" return bytes.size") + if (hasFlow) { + appendLine(" when (val target = jobHandle.toCPointer()!!.asStableRef().get()) {") + appendLine(" is Job -> target.cancel()") + appendLine(" is KneFlowJob -> target.job.cancel()") + appendLine(" }") + } else { + appendLine(" jobHandle.toCPointer()!!.asStableRef().get().cancel()") + } appendLine("}") appendLine() + if (hasFlow) { + appendLine("@CName(\"${prefix}_kne_flowCredit\")") + appendLine("fun `${prefix}_kne_flowCredit`(jobHandle: Long) {") + appendLine(" if (jobHandle == 0L) return") + appendLine(" jobHandle.toCPointer()!!.asStableRef().get().credits.trySend(Unit)") + appendLine("}") + appendLine() + } - // Read a ByteArray from a StableRef handle - appendLine("@CName(\"${prefix}_kne_readByteArrayRef\")") - appendLine("fun `${prefix}_kne_readByteArrayRef`(handle: Long, outBuf: CPointer?, outLen: Int): Int {") - appendLine(" val bytes = handle.toCPointer()!!.asStableRef().get()") - appendLine(" val writeLen = minOf(bytes.size, outLen)") - appendLine(" bytes.forEachIndexed { i, b -> if (i < writeLen) outBuf?.set(i, b) }") - appendLine(" return bytes.size") - appendLine("}") - appendLine() } /** Generate a suspend function bridge (instance method). */ private fun StringBuilder.appendSuspendMethod(fn: KneFunction, cls: KneClass, prefix: String) { val symbolName = "${prefix}_${cls.simpleName}_${fn.name}" val paramList = buildExpandedParamList(fn.params) - val allParams = "handle: Long${if (paramList.isNotEmpty()) ", $paramList" else ""}, _contPtr: Long, _excPtr: Long, _cancelOut: CPointer?" + val allParams = "handle: Long${if (paramList.isNotEmpty()) ", $paramList" else ""}, _contPtr: Long, _excPtr: Long, _callId: Long, _cancelOut: CPointer?" appendLine("@CName(\"$symbolName\")") appendLine("fun `$symbolName`($allParams) {") @@ -2197,35 +2176,34 @@ class NativeBridgeGenerator { appendObjectParamConversions(fn) val callArgs = fn.params.joinToString(", ") { p -> buildCallArg(p.name, p.type) } - // Create Job for cancellation + // Create Job for cancellation; the JVM side owns (and disposes) the StableRef appendLine(" val _job = Job()") - appendLine(" val _jobRef = StableRef.create(_job)") - appendLine(" _cancelOut?.pointed?.value = _jobRef.asCPointer().toLong()") + appendLine(" _cancelOut?.pointed?.value = StableRef.create(_job).asCPointer().toLong()") - // Launch coroutine - appendLine(" CoroutineScope(_job + Dispatchers.Default).launch {") + // ATOMIC: the body always runs, so exactly one completion stub fires even if cancelled before start + appendLine(" CoroutineScope(_job + Dispatchers.Default).launch(start = CoroutineStart.ATOMIC) {") appendLine(" try {") // Call suspend function and encode result if (fn.returnType == KneType.UNIT) { appendLine(" obj.${fn.name}($callArgs)") - appendLine(" val _contFn = _contPtr.toCPointer Unit>>()!!") + appendLine(" val _contRaw = _contPtr.toCPointer Unit>>()!!") + appendLine(" val _contFn = { hasValue: Int, value: Long -> _contRaw.invoke(_callId, hasValue, value) }") appendLine(" _contFn.invoke(1, 0L)") } else { appendLine(" val _result = obj.${fn.name}($callArgs)") - appendLine(" val _contFn = _contPtr.toCPointer Unit>>()!!") + appendLine(" val _contRaw = _contPtr.toCPointer Unit>>()!!") + appendLine(" val _contFn = { hasValue: Int, value: Long -> _contRaw.invoke(_callId, hasValue, value) }") appendSuspendResultEncode("_result", fn.returnType) } appendLine(" } catch (_e: CancellationException) {") - appendLine(" val _excFn = _excPtr.toCPointer Unit>>()!!") + appendLine(" val _excFn = { msg: Long -> _excPtr.toCPointer Unit>>()!!.invoke(_callId, msg) }") appendLine(" _excFn.invoke(0L)") appendLine(" } catch (_e: Throwable) {") - appendLine(" val _excFn = _excPtr.toCPointer Unit>>()!!") + appendLine(" val _excFn = { msg: Long -> _excPtr.toCPointer Unit>>()!!.invoke(_callId, msg) }") appendLine(" val _msgRef = StableRef.create(_e.message ?: _e::class.simpleName ?: \"Unknown error\")") appendLine(" _excFn.invoke(_msgRef.asCPointer().toLong())") - appendLine(" } finally {") - appendLine(" _jobRef.dispose()") appendLine(" }") appendLine(" }") appendLine("}") @@ -2290,7 +2268,7 @@ class NativeBridgeGenerator { val flowType = fn.returnType as KneType.FLOW val elemType = flowType.elementType val paramList = buildExpandedParamList(fn.params) - val allParams = "handle: Long${if (paramList.isNotEmpty()) ", $paramList" else ""}, _nextPtr: Long, _errorPtr: Long, _completePtr: Long, _cancelOut: CPointer?" + val allParams = "handle: Long${if (paramList.isNotEmpty()) ", $paramList" else ""}, _nextPtr: Long, _errorPtr: Long, _completePtr: Long, _callId: Long, _cancelOut: CPointer?" appendLine("@CName(\"$symbolName\")") appendLine("fun `$symbolName`($allParams) {") @@ -2299,27 +2277,29 @@ class NativeBridgeGenerator { appendObjectParamConversions(fn) val callArgs = fn.params.joinToString(", ") { p -> buildCallArg(p.name, p.type) } + // The JVM side owns (and disposes) the StableRef; credits bound the number of undelivered elements appendLine(" val _job = Job()") - appendLine(" val _jobRef = StableRef.create(_job)") - appendLine(" _cancelOut?.pointed?.value = _jobRef.asCPointer().toLong()") + appendLine(" val _credits = kotlinx.coroutines.channels.Channel(kotlinx.coroutines.channels.Channel.UNLIMITED)") + appendLine(" repeat($FLOW_INITIAL_CREDITS) { _credits.trySend(Unit) }") + appendLine(" _cancelOut?.pointed?.value = StableRef.create(KneFlowJob(_job, _credits)).asCPointer().toLong()") - appendLine(" CoroutineScope(_job + Dispatchers.Default).launch {") + appendLine(" CoroutineScope(_job + Dispatchers.Default).launch(start = CoroutineStart.ATOMIC) {") appendLine(" try {") appendLine(" val _flow = obj.${fn.name}($callArgs)") appendLine(" _flow.collect { _value ->") - appendLine(" val _nextFn = _nextPtr.toCPointer Unit>>()!!") + appendLine(" _credits.receive()") + appendLine(" val _nextFn = { value: Long -> _nextPtr.toCPointer Unit>>()!!.invoke(_callId, value) }") appendFlowElementEncode("_value", elemType) appendLine(" }") - appendLine(" val _completeFn = _completePtr.toCPointer Unit>>()!!") + appendLine(" val _completeFn = { _completePtr.toCPointer Unit>>()!!.invoke(_callId) }") appendLine(" _completeFn.invoke()") appendLine(" } catch (_e: CancellationException) {") - appendLine(" // silently cancelled — JVM side already knows") + appendLine(" // Terminal signal with no message: lets the JVM side release its resources") + appendLine(" _errorPtr.toCPointer Unit>>()!!.invoke(_callId, 0L)") appendLine(" } catch (_e: Throwable) {") - appendLine(" val _errorFn = _errorPtr.toCPointer Unit>>()!!") + appendLine(" val _errorFn = { msg: Long -> _errorPtr.toCPointer Unit>>()!!.invoke(_callId, msg) }") appendLine(" val _msgRef = StableRef.create(_e.message ?: _e::class.simpleName ?: \"Unknown error\")") appendLine(" _errorFn.invoke(_msgRef.asCPointer().toLong())") - appendLine(" } finally {") - appendLine(" _jobRef.dispose()") appendLine(" }") appendLine(" }") appendLine("}") diff --git a/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/nna/plugin/ir/KneIR.kt b/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/nna/plugin/ir/KneIR.kt index 6a0a4905..8a6f1ab9 100644 --- a/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/nna/plugin/ir/KneIR.kt +++ b/plugin-build/plugin/src/main/kotlin/dev/nucleusframework/nna/plugin/ir/KneIR.kt @@ -17,6 +17,16 @@ data class KneDataClass( val fqName: String, val fields: List, val isCommon: Boolean = false, + val source: KneSourceDecl? = null, +) : Serializable + +/** + * Verbatim source of a value-type declaration (enum / data class), copied as-is to the JVM side + * so that the JVM variant matches the native one (body members, defaults, KDoc, annotations). + */ +data class KneSourceDecl( + val text: String, + val imports: List = emptyList(), ) : Serializable data class KneInterface( @@ -55,6 +65,7 @@ data class KneEnum( val fqName: String, val entries: List, val constructorParams: List = emptyList(), + val source: KneSourceDecl? = null, ) : Serializable data class KneConstructor( diff --git a/plugin-build/plugin/src/test/kotlin/dev/nucleusframework/nna/plugin/codegen/FfmProxyGeneratorValueTypeSourceTest.kt b/plugin-build/plugin/src/test/kotlin/dev/nucleusframework/nna/plugin/codegen/FfmProxyGeneratorValueTypeSourceTest.kt new file mode 100644 index 00000000..74058b2e --- /dev/null +++ b/plugin-build/plugin/src/test/kotlin/dev/nucleusframework/nna/plugin/codegen/FfmProxyGeneratorValueTypeSourceTest.kt @@ -0,0 +1,136 @@ +package dev.nucleusframework.nna.plugin.codegen + +import dev.nucleusframework.nna.plugin.analysis.PsiSourceParser +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder + +class FfmProxyGeneratorValueTypeSourceTest { + + @get:Rule + val tmp = TemporaryFolder() + + @Test + fun `enum is emitted exactly as declared on native side`() { + val files = generate( + """ + package demo + + import kotlinx.cinterop.ExperimentalForeignApi + import kotlin.math.abs + + /** Arithmetic operation. */ + enum class Operation(val symbol: String) { + ADD("+") { + override fun apply(a: Int, b: Int) = a + b + }, + SUBTRACT("-") { + override fun apply(a: Int, b: Int) = abs(a - b) + }; + + abstract fun apply(a: Int, b: Int): Int + + companion object { + fun fromSymbol(symbol: String): Operation? = entries.firstOrNull { it.symbol == symbol } + } + } + """, + ) + + assertEquals( + """ + // Auto-generated by kotlin-native-export plugin. Do not modify. + package demo + + import kotlin.math.abs + + /** Arithmetic operation. */ + enum class Operation(val symbol: String) { + ADD("+") { + override fun apply(a: Int, b: Int) = a + b + }, + SUBTRACT("-") { + override fun apply(a: Int, b: Int) = abs(a - b) + }; + + abstract fun apply(a: Int, b: Int): Int + + companion object { + fun fromSymbol(symbol: String): Operation? = entries.firstOrNull { it.symbol == symbol } + } + } + + """.trimIndent(), + files.getValue("Operation.kt"), + ) + } + + @Test + fun `data class keeps defaults and body members`() { + val files = generate( + """ + package demo + + data class BinaryPayload(val name: String = "blob", val data: ByteArray) { + val size: Int get() = data.size + + override fun equals(other: Any?): Boolean = other is BinaryPayload && name == other.name && data.contentEquals(other.data) + override fun hashCode(): Int = 31 * name.hashCode() + data.contentHashCode() + } + """, + ) + + assertEquals( + """ + // Auto-generated by kotlin-native-export plugin. Do not modify. + package demo + + data class BinaryPayload(val name: String = "blob", val data: ByteArray) { + val size: Int get() = data.size + + override fun equals(other: Any?): Boolean = other is BinaryPayload && name == other.name && data.contentEquals(other.data) + override fun hashCode(): Int = 31 * name.hashCode() + data.contentHashCode() + } + + """.trimIndent(), + files.getValue("BinaryPayload.kt"), + ) + } + + @Test + fun `nested enum is re-indented to top level`() { + val files = generate( + """ + package demo.other + + class Holder { + enum class Mode { + ON, + OFF, + } + } + """, + ) + + assertEquals( + """ + // Auto-generated by kotlin-native-export plugin. Do not modify. + package demo + + enum class Mode { + ON, + OFF, + } + + """.trimIndent(), + files.getValue("Mode.kt"), + ) + } + + private fun generate(source: String): Map { + val file = tmp.newFile("Source.kt").apply { writeText(source.trimIndent()) } + val module = PsiSourceParser().parse(listOf(file), "demo", emptyList()) + return FfmProxyGenerator().generate(module, "demo") + } +}