mcpbeat Sign in

Jna Native Binding Traps Agent Skill

Hand-writing a JVM binding for a C library with JNA (Java Native Access) — the open-flags option that means something else on Windows, structs read by raw offset, callbacks the binding holds weakly, search paths registered too late, and proving which file was actually opened. Reach for it when a binding works on every developer machine and fails on a clean one, or when the very first symbol lookup fails with "the specified module could not be found" while the library is sitting right there.

2k tokens
context cost
the whole folder, loaded on every use
1
files
instructions only
0
copies elsewhere
how many repositories repackaged it
100
stars on the repo
on the repository, not the skill itself

Install

one command, takes just this skill from the repository
npx skills add https://github.com/maxrave-dev/kotlin-footguns --skill jna-native-binding-traps

The instruction itself

3 sections, as written by the author

JNA native binding traps

JNA (Java Native Access) maps a Java/Kotlin interface onto an already-compiled C library at

runtime. Nothing checks that your declarations match the header: a wrong field order, a wrong

open flag or a collected callback are all silent at link time and surface much later, somewhere

else. Write the binding defensively, and log enough to tell a working setup from a broken one.

Verification commands below run against the exact jar you ship, e.g.

JAR=$(find ~/.gradle/caches -name 'jna-<version>.jar' | head -1).

Traps

The open-flags option is POSIX-only — the same number means something else on Windows.

Library.OPTION_OPEN_FLAGS is forwarded verbatim to the platform's loader call; JNA does not

translate it. Confirm on your pinned jar:

javap -p -c -classpath "$JAR" com.sun.jna.NativeLibrary | grep -n 'open-flags\|Native.open'

the option key is read and its value flows into Native.open(String, int). On POSIX, 2 is "resolve everything now". On

Windows the same 2 asks the loader to map the file as plain data: it appears to load, then

every symbol lookup fails, and the message names the *function* while blaming a missing module.

Native.load(
    name,
    MediaLibrary::class.java,                                   // adapted: renamed
    if (Platform.isWindows()) {
        emptyMap<String, Any>()
    } else {
        mapOf<String, Any>(Library.OPTION_OPEN_FLAGS to 2)      // resolve-now, private scope
    },
)

Why you wanted that flag at all: loading publicly publishes the whole dependency closure. JNA's

default publishes the library's symbols — and those of everything it drags in — into the process-

wide namespace, where unrelated natives (a rendering toolkit's own libraries) can bind to them by

accident. One such accidental binding produced a hard native stop inside a library the application

never calls, only when the engine shared a process with the UI toolkit; the same library loaded in a

bare JVM was stable. Loading privately keeps the symbols to your handle — and is why the Windows

branch above is *empty* rather than some other flag: Windows never publishes exports process-wide.

Always log the file that was actually opened, not the name you asked for.

val resolved = runCatching { NativeLibrary.getInstance(name).file?.absolutePath }
    .getOrNull() ?: "<unknown>"
Logger.d(TAG, "Loaded as '$name' (API ${version shr 16}.${version and 0xFFFF}) from $resolved")   // version = the library's own version call

On any machine with a system-wide copy installed, a working bundle and a completely broken bundle

both load and both work. The resolved path is the only thing that distinguishes them, and without

it the bug is invisible until a clean machine reports it.

A bare library name is not a file name. JNA maps "foo" onto the platform convention

(libfoo.so, libfoo.dylib, foo.dll). Bundles usually ship only the *versioned* file and no

unversioned symlink, so every bare name misses and the loader quietly falls through to a system copy.

Try absolute paths of the files you actually shipped first, plain names only as a fallback.

Folklore about when the search-path property is read does not survive the bytecode. A comment

in the codebase this was mined from claimed the JNA path property is parsed once at class init,

making later writes a no-op. Disassembling the pinned jar shows the opposite: jna.library.path

is re-read on every load call, while the static initializer's own search set is filled from

*other* sources (the web-start path and the platform path property) and consulted only as a

fallback after the first open attempt fails. Two rules fall out:

  • Verify against the property *name*, not a field name:

javap -p -c -classpath "$JAR" com.sun.jna.NativeLibrary | grep -n 'jna.library.path' — where

those reads land (a per-call method versus static {}) *is* the answer, and it is

version-scoped, so re-run it on every JNA bump. Grepping for the internal field instead shows a

static {} assignment and invites exactly the wrong conclusion.

  • NativeLibrary.addSearchPath(name, dir) remains the explicit per-library-name API and reads clearest

— use it because it is direct, not because "the property is too late". And do not expect the JVM's own

System.loadLibrary to honour the JNA property: it resolves through java.library.path and the class

loader, and nothing in the jar copies one property into the other.

Structs are read by raw offset. A field added, removed or reordered upstream is wrong bytes,

never a clean link error. Two defences, both cheap:

  • Map only the *stable prefix* of a struct you exclusively read — the fields that have existed

since the oldest API version you support. The binding then reads exactly those bytes and can

never run past the end, including against older builds where the trailing fields do not exist

at all. Only safe for read-only structs; one you write back must be complete.

  • Compare the runtime API version against the one you wrote the layouts against, and log loudly

on a major difference. Record in a comment which library version and which header the

layouts were read from, and re-verify on every major bump.

if ((version shr 16) != (EXPECTED_API_VERSION shr 16)) {
    Logger.e(TAG, "client API major ${version shr 16} != expected ${EXPECTED_API_VERSION shr 16}; " +
        "struct layouts must be re-checked")
}

Callbacks are held weakly. javap -classpath "$JAR" com.sun.jna.CallbackReference | head -2

shows it extends WeakReference<Callback>. A collected callback leaves native code holding a

function pointer to nothing, and it fires whenever the native side next feels like it — so the

failure has no relationship to the code that registered it. Keep every registered callback in a

field for as long as it stays registered, and deregister before freeing the object it points at.

Nothing terminates your array arguments. A C function taking a char ** terminated by NULL

needs the trailing null element from you; JNA does not append one.

val argv = arrayOfNulls<String>(args.size + 1)   // last element stays null on purpose
args.forEachIndexed { i, a -> argv[i] = a }

Return an owned buffer as a pointer, not as a String. When the C contract says the caller

owns the result and must hand it back, mapping it to String loses the address — so it can never

be handed back, and that memory is never reclaimed for the life of the process. Map it to

Pointer, read the value out, then call the library's own release function.

Verifying it

Run against the exact pinned jar: JAR=$(find ~/.gradle/caches -name 'jna-5.19.1.jar' ! -iname '*sources*' | head -1). Step 2 additionally needs the repo checkout, not just the jar — run it from the repo root.

  • The three bytecode claims above hold on the pinned version, not just an older release:
   javap -p -c -classpath "$JAR" com.sun.jna.NativeLibrary | grep -n 'open-flags\|Native.open'
   javap -p -c -classpath "$JAR" com.sun.jna.NativeLibrary | awk '/^  (private|public|static).*\(.*\)|^  static \{\};/{m=$0} /jna.library.path/{print m}'
   javap -classpath "$JAR" com.sun.jna.CallbackReference | head -2

Pass condition: the option key flows into Native.open(String, int); every jna.library.path

hit attributes to loadLibrary(...), never static {};; CallbackReference extends WeakReference<Callback>.

  • A real consumer applies the documented POSIX-only branch, not just this file's prose:
   grep -n -B3 "OPTION_OPEN_FLAGS to 2" core/media/media-jvm/src/main/java/com/simpmusic/media_jvm/mpv/MpvLibrary.kt

Pass condition: the three lines of context show if (Platform.isWindows()) / emptyMap<...>() /

} else { immediately above the OPTION_OPEN_FLAGS hit — the flag sits in the else branch.

How to use it

Copy the folder

Take maxrave-dev/jna-native-binding-traps from the repository into ~/.claude/skills for personal use, or into .claude/skills inside a project.

Check the name does not clash

The agent identifies a skill by the name field in its header. Two skills with the same name cannot sit side by side — one of them will be ignored.