kffi is a low-level foreign function interface (FFI) layer for native memory
access and native calls, written in Kotlin Multiplatform. It is a standalone
runtime library for projects that consume C libraries.
The repository was extracted from wgpu4k-native and now contains the standalone KFFI runtime and its benchmark modules.
This is a runtime-only module: binding generation is handled by the separate kextract tool (see Binding generation).
The expect/actual contract lives in commonMain (org.graphiks.kffi):
NativeAddress, MemoryBuffer, MemoryAllocator (memoryScope,
globalMemory), CString, and Callback/CallbackRuntime. The memory safety
contract is unified across all three backends (spec P3).
- Quickstart
- Backends
- Memory safety contract
unsafemode- Callbacks
- Versioning
- Contributing
- Project architecture
- CI/CD workflow
- Useful development commands
| Backend | Implementation | Notes |
|---|---|---|
| JVM | Panama FFM (java.lang.foreign) |
Confined arenas; downcalls through MethodHandle; jvmTarget 25 |
| Android | Kotlin engine + JNI (NativeEngine/UpcallEngine) |
.so bundled in the AAR; libffi fallback path |
| Native | kotlinx.cinterop |
iOS, macOS, Linux, Windows (MinGW), Android Native |
Release artifacts are consumed from Maven Central. Development snapshots
default to 1.0.0-SNAPSHOT and can be consumed from the Central Portal snapshot
repository, so consumers of those snapshots need both repositories:
// settings.gradle.kts - resolution repositories
dependencyResolutionManagement {
repositories {
mavenCentral()
maven {
name = "CentralPortalSnapshots"
url = uri("https://central.sonatype.com/repository/maven-snapshots/")
content {
includeGroup("org.graphiks")
}
}
}
}Gradle publication version selection uses the releaseVersion property, set
with -PreleaseVersion=...; without an explicit value, it defaults to
1.0.0-SNAPSHOT. The canonical publication workflow selects the version from
workflow dispatch input or a release tag and publishes through the single
aggregated ./gradlew publishToMavenCentral task. Development snapshots use the
Central Portal snapshot repository shown above; releases use Maven Central.
The group is org.graphiks. Publications follow the standard Kotlin
Multiplatform naming scheme:
| Context | Artifact | Notes |
|---|---|---|
| KMP consumer | org.graphiks.kffi:kffi |
Root artifact (metadata); Gradle resolves the platform variant automatically |
| JVM-only project | org.graphiks.kffi:kffi-jvm |
Requires a JDK with java.lang.foreign (25+) |
| Android project | org.graphiks.kffi:kffi-android |
AAR with release/debug variants; minSdk 28 |
| Native project | org.graphiks.kffi:kffi-<target> |
One artifact per Native target (see the list below) |
There is no aggregate kffi-native artifact: each Native target is published
separately. For a KMP consumer, declare the root org.graphiks.kffi:kffi artifact;
Gradle selects the variant for the target being compiled.
Published Native targets:
kffi-iosx64, kffi-iosarm64, kffi-iossimulatorarm64,
kffi-macosx64, kffi-macosarm64, kffi-linuxx64, kffi-linuxarm64,
kffi-mingwx64, kffi-androidnativearm64, kffi-androidnativex64.
M2.4 note: The standalone snapshot coordinate is
1.0.0-SNAPSHOT, effective after the M2.4 migration (independent versioning for the kffi module). Before this migration, the module inherited the host repository version.The examples below use
1.0.0-SNAPSHOT. Replace it with an explicitly selected published version when consuming a release.
// build.gradle.kts - KMP project
kotlin {
sourceSets {
commonMain.dependencies {
implementation("org.graphiks.kffi:kffi:1.0.0-SNAPSHOT") // or an explicitly selected published version
}
}
}// build.gradle.kts - JVM-only project
dependencies {
implementation("org.graphiks.kffi:kffi-jvm:1.0.0-SNAPSHOT")
}// build.gradle.kts - Android project
dependencies {
implementation("org.graphiks.kffi:kffi-android:1.0.0-SNAPSHOT")
}See docs/quickstart.md for a complete end-to-end example.
kffi is runtime-only: it provides the runtime engine for memory access,
downcalls, and upcalls, but not a binding generator. Kotlin bindings for C/ObjC
headers are generated by
kextract, a Kotlin generator that
uses libclang and targets the public org.graphiks.kffi API:
- Memory-backed structs (
MemoryBuffer) with scalar and array access. - Per-function downcall wrappers with symbol resolution at load time.
- Callback declarations (
CallbackType) and upcall trampolines by signature shape.
The consumer workflow is: generate bindings with kextract, link the bindings to kffi, and load the native library during startup (see below).
:kffi-x11 provides JVM-only, low-level X11 bindings for JDK 25. Its generated
sources are in org.graphiks.kffi.x11.generated and load libX11.so.6,
libXext.so.6, and libXcomposite.so.1. The module depends on
:kffi-posix; it does not provide a higher-level X11 event-loop layer.
The generated core surface includes graphics-context creation, state setters,
color parsing/allocation/query operations, and X11 drawing primitives for
points, lines, segments, rectangles, arcs, and polygons. It also exposes
pixmap creation, copying, clearing, window background/border pixmap helpers,
and image transfer entry points. Native records such as XColor, XPoint,
XSegment, XRectangle, and XArc are emitted by kextract, while opaque Xlib
records remain MemorySegment pointers where their nested ABI is not safely
laid out by the pinned generator.
Window hosting also exposes XInitThreads, XMapRaised, and
XMoveResizeWindow. Call XInitThreads before any other Xlib call when using
Xlib from multiple threads, including before AWT opens a display.
XSetErrorHandler accepts a native callback pointer and returns the previous
process-global handler. Its callback has signature int(Display*, XErrorEvent*);
keep the callback arena alive until the handler is replaced, never throw through
the C callback, and restore the previous handler before closing the arena.
The generated XErrorEvent alias supplies LP64 storage and accessors for all
native fields, with size, alignment, and field offsets checked against the C
headers. Callback event memory is borrowed: copy needed fields during the call.
The bindings are generated with the pinned
kextract revision
9252fb417ea91dae882a6a9e9d06ab672c50adc3. The Docker base image is pinned
to eclipse-temurin:25-jdk-noble@sha256:e94f1dc880339ab3884b69176b79c8dc4124b722e059c7ff7f0bf53b603a46f8;
it supplies the declared Ubuntu X11 development packages at image-build time
rather than claiming an APT snapshot pin. Regenerate for Linux after
initializing the submodule:
git submodule update --init --recursive
scripts/gen-kffi-x11.shGeneration is not part of the Gradle build. The module's contract tests do not start or require an X server.
For the Linux-only screenshot integration, no physical X server or window
manager is required: scripts/run-x11-integration.sh starts Xvfb and runs
:kffi-x11:x11IntegrationTest. CI installs the Ubuntu packages actually used
there: xvfb, x11-apps, libx11-6, libxext6, libxcomposite1, libxtst6,
and imagemagick.
Use the runner for the full local flow:
scripts/run-x11-integration.shTo run the same flow in the Wayland-style Docker wrapper, with X11 tools and the JDK supplied by the image:
scripts/docker-x11-integration.shThe Docker wrapper requires a working Docker daemon and persists Gradle
dependencies in the kffi-x11-integration-gradle-cache volume by default.
Override the image or cache volume with KFFI_X11_INTEGRATION_IMAGE or
KFFI_X11_INTEGRATION_GRADLE_CACHE_VOLUME.
The runner requires Linux, exports KFFI_X11_INTEGRATION=1, and writes
artifacts to kffi-x11/build/x11-integration/ by default. Supported overrides
are KFFI_X11_INTEGRATION_REPO_ROOT, KFFI_X11_INTEGRATION_GRADLE,
KFFI_X11_INTEGRATION_ARTIFACT_DIR, and KFFI_X11_ARTIFACT_DIR.
To run the Gradle task directly, opt in explicitly:
KFFI_X11_INTEGRATION=1 ./gradlew :kffi-x11:x11IntegrationTestThe direct Gradle command does not start Xvfb; DISPLAY must reference an
existing X server. Prefer scripts/run-x11-integration.sh for the
self-contained headless flow.
The integration test draws four named-color rectangles in the window's
quadrants, captures the result as window.png, and validates the center pixel
of each quadrant. The generated binding contract test also checks that the
core GC, color, geometry, and pixmap declarations are present.
The pinned generator cannot safely emit layouts for Xlib records with nested
declarations or LP64 padding. XEvent, XImage, XWindowAttributes,
XWMHints, XGC, and XShmSegmentInfo therefore remain generated
MemorySegment pointer APIs without raw record accessors. The generator's
XEventStorage (192-byte event storage) and XShmSegmentInfoCompat
(32-byte LP64-padded storage) declarations are compatibility shims only; they
do not replace the native records passed to Xlib. The generator validates those
shim sizes, alignments, and native offsets with C _Static_assert checks.
:kffi-objc includes a macOS-only AppKit screenshot integration. It renders
four colored quadrants through the generated Objective-C bindings, validates the
PNG pixels, and writes screenshot.png plus diagnostics to
kffi-objc/build/objc-integration/.
Run it locally on macOS with:
scripts/run-objc-integration.shThe objc-integration CI job runs the same script and uploads the generated
image as a workflow artifact. The Gradle task can also be invoked directly with
KFFI_OBJC_INTEGRATION=1.
:kffi-win32 provides JVM-only Win32 bindings generated from the Windows SDK.
The initial surface covers selected functions from user32.dll,
kernel32.dll, gdi32.dll and dwmapi.dll, under the package
org.graphiks.kffi.win32.generated.
The generated Kotlin sources are versioned in this repository. Regeneration is
a maintainer operation on Windows; CI compiles and tests the checked-in sources
but does not run the generator.
Generation requires Windows, Git Bash, JDK 25, the pinned kextract submodule
and Windows SDK 10.0.28000.0 exactly. That SDK version is required for
reproducible committed output, and must be installed under
C:/Program Files (x86)/Windows Kits/10/Include/10.0.28000.0; other SDK
versions and installation roots are not used. The
generator is intentionally separate from the Gradle build:
git submodule update --init --recursive
scripts/gen-kffi-win32.sh
./gradlew :kffi-win32:compileKotlinJvm --no-daemonSee kffi-win32/README.md for the generation inputs,
the --check mode, the Windows-specific setup, and the window screenshot
integration test.
- Load the library into the process with
System.loadLibrary("monlib")(or place it onjava.library.path). Symbols resolved bySymbolLookup.loaderLookup()exist only for libraries loaded by the classloader. - Resolve symbols to raw addresses:
import org.graphiks.kffi.findOrThrow
val symbol: Long = findOrThrow("mon_symbole") // UnsatisfiedLinkError if absentThe JVM kffi runtime uses restricted java.lang.foreign APIs (Linker,
MethodHandles) itself. Start the JVM with
--enable-native-access=ALL-UNNAMED. Without this flag, the JVM emits a
warning and will block the call in a future JDK version. The flag is also
required for upcalls emitted by kextract on the direct FFM fallback path.
The AAR bundles the libkffi.so engine (JNI), which is loaded automatically by
System.loadLibrary("kffi") during NativeEngine initialization. The consumed
library is loaded dynamically:
import org.graphiks.kffi.engine.NativeEngine
val handle = NativeEngine.loadNativeLibrary("/data/app/.../libmonlib.so") // dlopen
val symbol = NativeEngine.resolveSymbolIn(handle, "mon_symbole")The AAR bundles consumer R8 rules and the arm64-v8a and x86_64 ABIs.
Linking happens at compile time through cinterop (.def files): bindings
generated by kextract and the kffi module are linked to the native library at
final link time. No manual dlopen is needed.
NativeAddress- a raw native address (a value class overLong). It is unbounded by nature: typed access goes throughMemoryBuffer(bounded) or theunsafeoption.MemoryBuffer- a bounded buffer over a native address:handler(address) andsize(bytes). It provides scalar and array access for Byte, Short, Int, Long, Float, Double, signed and unsigned types, and pointers.CString- a C string (UTF-8, terminated by\0): allocate withMemoryAllocator.allocateFromand read withtoKString().MemoryAllocator- a confined arena with allocation (allocate,allocateBuffer,allocateFrom,bufferOf,bufferOfAddress,bufferOfAddresses), closing (close), andmemoryScope { }, which closes the arena at the end of the block.globalMemoryis a process-lifetime arena.
import org.graphiks.kffi.*
memoryScope { allocator ->
val buffer = allocator.allocateBuffer(size = 16uL)
buffer.writeInt(value = 42, offset = 0uL)
val value = buffer.readInt(offset = 0uL) // 42
buffer.readLong(offset = 12uL)
// IndexOutOfBoundsException: MemoryBuffer access out of bounds: offset=12 width=8 size=16
}Every typed scalar and array access is checked: offset + elementSize <= size.
Out-of-bounds access throws IndexOutOfBoundsException with the offset and
size in the message. Checks are enabled by default. On JVM and Android, the
runtime unsafe = true option skips these checks. On Native, the runtime
argument is ignored; checks follow the compile-time KFFI_NATIVE_UNSAFE value.
Decision I2-(a): the arena/session scope lives in MemoryBuffer, not in
NativeAddress.
- A buffer created through
MemoryAllocator(JVM) carries the arena's scoped segment. Access after the arena'sclose()throwsIllegalStateException. - On Android and Native, closing
MemoryAllocatorreleases its arena without a per-buffer close guard; accessing allocator-backed memory afterward is undefined behavior. - A buffer created from a raw address (
MemoryBuffer(addr, size)) has no scope. Post-close access is not detected (documented UB on all three backends).
On the JVM, the arena is Arena.ofConfined(): scoped buffers and close() are
confined to the creating thread. Access from another thread throws
WrongThreadException. memoryScope follows the same rule.
On the JVM, unsafe access uses the raw address (sun.misc.Unsafe), so there is
no thread check; the allocator close guard still applies. A buffer built from a
raw address has no confinement (null guard, documented UB) and is the path for
deliberate cross-thread sharing at the consumer's own risk. Android also uses
the runtime flag, but does not provide the JVM close guard after its arena is
closed.
Two buffers over the same memory area see each other: writes through one are visible through the other. No lock is provided; synchronization is the consumer's responsibility. Cross-thread sharing is also limited by JVM arena confinement.
On JVM and Android backends, unsafe = true is a runtime option that disables
bounds checks in either of two ways:
- Per allocator:
MemoryAllocator(unsafe = true)propagates the option to all buffers created by that allocator. - Per buffer:
MemoryBuffer(addr, size, unsafe = true)enables it locally.
The default is false (bounds checks enabled). In unsafe mode, every
out-of-bounds access becomes undefined behavior (UB): no exception is
raised, and memory corruption is possible. This is deliberate and intended for
hot paths only.
Lifetime policy P2: unsafe mode does not remove lifetime discipline. Unsafe
buffers retain the JVM allocator close guard, while Android allocator-backed
buffers become invalid when their arena closes; raw-address buffers have no
guard on either backend. Only bounds checks are skipped by the runtime option.
On Native backends, unsafe is compile-time fixed: the runtime argument is
ignored and the value comes from the build-time constant
KFFI_NATIVE_UNSAFE in MemoryBuffer.native.kt, currently false. Therefore
current Native builds keep bounds checks enabled even when unsafe = true is
passed. Native distributions cannot switch this at runtime; change the
constant and rebuild the module to produce a different policy.
Callbacks (upcalls from Native to Kotlin) are managed by CallbackRuntime and
backend-specific upcall engines (JvmUpcallEngine, UpcallEngine on Android).
Code generated by kextract provides CallbackType descriptors, trampolines,
and dispatchers; the consumer registers a Kotlin lambda with
CallbackRuntime.register.
Lifecycle:
CallbackRegistration.close()closes the registration and prevents further delivery.isClosedbecomes true immediately;isQuiescentbecomes true only after the route is removed and all in-flight Native deliveries have returned (tracked byinFlight).CallbackRegistration.onQuiescentruns each registered action exactly once after closure and the last admitted delivery. An action registered after quiescence runs immediately; action failures use the registration'sCallbackExceptionHandler.CallbackPolicy.ONCEdelivers a callback once, then unpublishes the slot after the first delivery (claim).REPEATINGdelivers while the registration remains open.CallbackRuntime.prepare/activateForNativeCallprovide a transactional pattern for generated one-shot Native calls: prepare, activate at the call, and close on failure.CallbackRuntime.rearmAfterNativeQuiescencerearms a slot without userdata (@UnsafeCallbackRearmApi). The consumer must establish Native quiescence before rearming.- No exception crosses the Native boundary.
dispatchSafelyroutes failures to the registration'sCallbackExceptionHandler(onError), or to the fallback channel if routing fails. Its result-returning overload returns the supplied default value when routing, admission, callback execution, or delivery teardown fails.
Trampoline lifetime: on the JVM, stubs are allocated in a global arena with
process lifetime. On Android, JNI trampolines use explicit management through
the four-argument allocateTrampoline(dispatcherClass, dispatchMethod, dispatchJvmSignature, dispatchAbiSignature) API, which prepares a dynamic
libffi closure for the callback shape. The final ptr in the ABI signature is
the routing userdata; pointer and struct addresses are carried to Kotlin as
jlong values, so native code does not create Java wrapper objects. In both
cases, CallbackRegistration controls routing; closing it removes the slot
from the token table, and a token is never reused. Before Android
freeTrampoline, generated or native code must unregister the callback from
the C library, stop and join every native callback producer, and only then
free the closure. CallbackRegistration.isQuiescent alone cannot account for
a native callback that has not entered the Kotlin dispatcher yet.
kffi follows strict SemVer. 1.0.0 is the first stable contract: the P1-P4
redesign (downcall/upcall engines, I2-a lifetime policy, I3 unsafe option, and
M1 optimization) is the final redesign before 1.0. Public APIs are stable from
this version; breaking changes require a major version bump.
- Releases use
x.y.zand the current runtime version is being prepared as1.0.0. - The Gradle publication version is selected with
releaseVersion, using-PreleaseVersion=...; without an override, it defaults to1.0.0-SNAPSHOT. - The canonical publication workflow uses the aggregated root task
./gradlew publishToMavenCentral, with workflow dispatch input or a release tag selecting the publication version. - The current runtime version is exposed as
Kffi.VERSION(1.0.0, starting with the M2.4 module version).
Contributions are welcome. Please see:
This repository is a standalone Kotlin Multiplatform build. The root
settings.gradle.kts includes these modules:
| Module | Purpose |
|---|---|
:kffi |
The multiplatform runtime library and its JVM, Android, and Native tests |
:kffi-wayland |
JVM-only low-level Wayland bindings |
:kffi-x11 |
JVM-only generated X11 bindings; see X11 bindings |
:kffi-objc |
JVM-only generated macOS Objective-C, Foundation, and AppKit bindings |
:kffi-coretext |
Optional JVM-only source-byte CoreText font creation, raw metrics and bounded Darwin reads |
:kffi-harfbuzz |
Optional typed HarfBuzz shaping bindings for JVM (macOS/Linux x64+arm64) and Android |
:kffi-benchmark-spi |
Shared multiplatform benchmark model and test contracts; internal, not published |
:kffi-benchmark-jvm |
JVM JMH benchmarks for kffi |
:kffi-benchmark-native |
Kotlin/Native benchmark harness |
:kffi-benchmark-android |
Android instrumented benchmark harness |
The :kffi module keeps the public expect declarations and common contracts
in commonMain, with platform implementations in the JVM, Android, and Native
source sets. The JVM implementation uses Panama FFM, Android uses the Kotlin
and JNI engines, and Native uses Kotlin/Native cinterop. Benchmark modules
depend on the runtime and benchmark SPI rather than introducing another
runtime module.
The dedicated KFFI test workflow (.github/workflows/ci.yml) runs on
macOS, Ubuntu, and Windows with JDK 25. It runs ./gradlew :kffi:jvmTest :kffi-objc:jvmTest :kffi:testAndroidHostTest on every matrix entry and runs
the Windows-only ./gradlew :kffi-win32:jvmTest plus the
./gradlew :kffi-win32:win32IntegrationTest window screenshot test on
windows-latest. The screenshot output is uploaded as a workflow artifact.
It also performs these
platform-specific checks:
- macOS compiles the iOS and macOS callback token codecs, then runs either
./gradlew :kffi:macosArm64Testor./gradlew :kffi:macosX64Testaccording to the host architecture. - Ubuntu compiles the Linux callback token codecs and runs
./gradlew :kffi:linuxX64Test. - Windows activates MSVC, compiles the MinGW callback token codec, and runs
./gradlew :kffi:mingwX64Test.
A dedicated Ubuntu job starts an Android API 35 x86_64 emulator and runs
./gradlew :kffi:connectedAndroidDeviceTest.
The benchmark workflow (.github/workflows/benchmark.yml) runs the JVM
JMH benchmark, compiles the macOS arm64 Native benchmark harness, and assembles
the Android debug test APK. The canonical root publication workflow publishes
artifacts through the single aggregated ./gradlew publishToMavenCentral task,
with dispatch/tag version selection.
./gradlew :kffi:jvmTest./gradlew \
:kffi:compileKotlinIosX64 \
:kffi:compileKotlinIosArm64 \
:kffi:compileKotlinIosSimulatorArm64 \
:kffi:compileKotlinMacosArm64 \
:kffi:compileKotlinMacosX64
if [[ "$(uname -m)" == "arm64" ]]; then
./gradlew :kffi:macosArm64Test
else
./gradlew :kffi:macosX64Test
fi./gradlew :kffi:compileKotlinLinuxX64 :kffi:compileKotlinLinuxArm64
./gradlew :kffi:linuxX64Test./gradlew :kffi:compileKotlinMingwX64
./gradlew :kffi:mingwX64Test./gradlew :kffi-benchmark-jvm:jmh
./gradlew :kffi-benchmark-native:compileKotlinMacosArm64
./gradlew :kffi-benchmark-android:assembleAndroidDeviceTest./gradlew tasksMIT - see LICENSE.