Consider this response regarding the Panama vs. JNA/FFM question:
The JNA calling for Java, is this not a case for FFI via Panama (assuming a Java 25 LTS base line).
I am leaning to not go with Panama. But how can I respond to this commenter?
Great question — Panama FFM was seriously considered, and it's likely the eventual destination. For this iteration we're staying with JNA. Reasoning:
-
Baseline. The SDK supports Java 17, and FFM isn't available there (it finalized in 22 via JEP 454). A JNA path would be needed regardless, so FFM would be an additional binding implementation, not a replacement.
-
Consumer friction. FFM's restricted native access means every consumer would need
--enable-native-access=...(or the manifest attribute) on their launcher as JEP 472 enforcement tightens. JNA works with zero configuration today. For an SDK, that flag becomes every downstream app's problem and a recurring support issue. (JNA sits on the same enforcement trajectory eventually, since it uses JNI internally — so this buys us time, not immunity.) -
The performance win doesn't materialize here. FFM's advantage over JNA is per-call marshalling overhead. Our C ABI surface is ~12 stable
extern "C"entry points carrying JSON-RPC strings; serialization cost dominates, and call frequency is bounded by agent-interaction rates, not tight loops. The latency delta would be unmeasurable end-to-end. -
Upcall complexity. The transport is bidirectional, so the runtime calls back into Java from native threads. JNA's
Callbacksemantics are well-worn; FFM upcall stubs require explicitArenalifetime management, where a stub outlived by a Rust-held function pointer is a JVM crash. -
GraalVM native-image. JNA's reachability metadata story is mature; FFM support (especially upcalls) is still maturing and varies by GraalVM release. Some plausible SDK consumers (Quarkus/Micronaut CLI tools) compile to native images.
That said, we're structuring the binding layer behind a small internal interface so an FFM implementation can be added later (e.g., as a multi-release JAR) without touching the transport logic. When the baseline moves past 17 and the JEP 472 endgame arrives, a ~12-function stable ABI makes that migration cheap.
Good instinct — FFM is probably where this lands eventually, but not in this iteration, for three reasons:
- We support Java 17, where FFM doesn't exist, so JNA is required anyway; FFM would be a second parallel implementation.
- FFM pushes
--enable-native-accessonto every consumer's launcher as JEP 472 tightens; JNA is zero-config for downstream users today. - The C ABI surface is ~12 fixed entry points carrying JSON-RPC strings, so JSON serialization dominates and FFM's call-overhead advantage is unmeasurable here. Bidirectional callbacks also add FFM
Arena/upcall-lifetime complexity that JNA'sCallbackhandles for free.
We're abstracting the binding layer behind an internal interface, so swapping in FFM later (once the baseline and JEP 472 timeline make it the clear win) is a contained change rather than a rewrite. Happy to expand any of this into the ADR if useful.
Two drafts above — a full five-point rationale if you want the reasoning on the record (useful if this thread effectively becomes ADR documentation), and a tighter three-point version if the comment thread just needs closure.
Both open by validating the suggestion rather than rebutting it, because the commenter isn't wrong — FFM is the right long-term answer, just not the right answer for a 17-baseline SDK with a JSON-dominated transport today. Framing it as "eventual destination, deliberately deferred" also preempts the follow-up question ("so when?") by pointing at the concrete triggers: baseline moving past 17 and JEP 472 enforcement arriving.
One thing worth deciding before you post: whether to commit publicly to the binding-layer abstraction. Both drafts mention it, and it's a cheap promise that makes the "not now" more credible — but if it's not actually in ADR-007 yet, you may want to add it there first so the comment points at something real.
Read copilot-sdk/java/docs/adr/adr-007-native-bundling-strategy.md.
I want to explore the ability to select Option 2, but use the maven-assembly-plugin to create an uber jar that contains the Java artifact and a select set of the native jars:
com.github:copilot-sdk-java-runtime:VERSION:linux-x64
com.github:copilot-sdk-java-runtime:VERSION:linux-arm64
com.github:copilot-sdk-java-runtime:VERSION:linuxmusl-x64
com.github:copilot-sdk-java-runtime:VERSION:linuxmusl-arm64
com.github:copilot-sdk-java-runtime:VERSION:darwin-x64
com.github:copilot-sdk-java-runtime:VERSION:darwin-arm64
com.github:copilot-sdk-java-runtime:VERSION:win32-x64
com.github:copilot-sdk-java-runtime:VERSION:win32-arm64
If we have such an uber jar, how can the java artifact select the correct native jar content for the JNA interaction?
Updated todo list
Read , lines 1 to 200
Read , lines 200 to 229
Now I have the full picture. Let me look at what native loading infrastructure exists in the Java SDK currently.
Searched for files matching **/java/src/**/*Native*, no matches
Searched for files matching **/java/src/**/*Platform*, 2 matches
Searched for files matching **/java/src/**/*Runtime*, 1 match
Searched for regex JNA|jna|native.*load|NativeLoader (**/java/src/**), 2 results
No native loading code exists yet in the Java SDK — this is greenfield. Here's how the uber-jar approach works with Option 2's per-platform classifier JARs:
Each per-platform JAR (copilot-sdk-java-runtime:VERSION:darwin-arm64, etc.) places its binary under a deterministic path inside the JAR:
native/darwin-arm64/runtime.node
native/darwin-arm64/platform.properties
When maven-assembly-plugin creates the uber-jar, it unpacks all dependencies and merges them. The resulting uber-jar contains:
com/github/copilot/sdk/... (Java classes)
native/linux-x64/runtime.node
native/linux-arm64/runtime.node
native/linuxmusl-x64/runtime.node
native/linuxmusl-arm64/runtime.node
native/darwin-x64/runtime.node
native/darwin-arm64/runtime.node
native/win32-x64/runtime.node
native/win32-arm64/runtime.node
public class NativeRuntimeLoader {
public Path loadRuntime() {
String classifier = detectPlatformClassifier();
String resourcePath = "native/" + classifier + "/runtime.node";
try (InputStream in = getClass().getClassLoader()
.getResourceAsStream(resourcePath)) {
if (in == null) {
throw new UnsupportedOperationException(
"No native runtime for platform: " + classifier);
}
Path cached = getCachePath(classifier);
if (!Files.exists(cached)) {
Files.createDirectories(cached.getParent());
Files.copy(in, cached);
// Make executable on Unix
cached.toFile().setExecutable(true);
}
return cached;
}
}
private String detectPlatformClassifier() {
String os = normalizeOs(System.getProperty("os.name"));
String arch = normalizeArch(System.getProperty("os.arch"));
String libc = "linux".equals(os) ? detectLinuxLibc() : "";
// Produces: "linux-x64", "linuxmusl-arm64", "darwin-arm64", "win32-x64", etc.
return (libc.isEmpty() ? os : os + libc) + "-" + arch;
}
private String detectLinuxLibc() {
// Read ELF PT_INTERP from /proc/self/exe
// If interpreter contains "/ld-musl-" → "musl"
// Otherwise → "" (glibc is the default/unmarked case for "linux-")
// ...
}
private Path getCachePath(String classifier) {
String version = getClass().getPackage().getImplementationVersion();
return Path.of(System.getProperty("user.home"),
".copilot", "runtime-cache", version, classifier, "runtime.node");
}
}Once extracted to a known filesystem path, JNA loads it directly:
NativeLibrary lib = NativeLibrary.getInstance(extractedPath.toString());
// Or via a mapped interface:
CopilotRuntime runtime = Native.load(extractedPath.toString(), CopilotRuntime.class);The beauty is that getResourceAsStream("native/darwin-arm64/runtime.node") works identically whether:
- The native lives in a separate classifier JAR on the classpath (normal dev dependency), OR
- It's been merged into an uber-jar by
maven-assembly-plugin
The classloader doesn't care which JAR file the resource came from — it searches the entire classpath. This means zero code changes between the two consumption models.
A consumer building a portable uber-jar would configure:
<plugin>
<artifactId>maven-assembly-plugin</artifactId>
<configuration>
<descriptorRefs>
<descriptorRef>jar-with-dependencies</descriptorRef>
</descriptorRefs>
</configuration>
</plugin>With all classifier JARs declared as dependencies:
<dependencies>
<dependency>
<groupId>com.github</groupId>
<artifactId>copilot-sdk-java</artifactId>
<version>${copilot.version}</version>
</dependency>
<!-- Include platforms you need -->
<dependency>
<groupId>com.github</groupId>
<artifactId>copilot-sdk-java-runtime</artifactId>
<version>${copilot.version}</version>
<classifier>linux-x64</classifier>
</dependency>
<dependency>
<groupId>com.github</groupId>
<artifactId>copilot-sdk-java-runtime</artifactId>
<version>${copilot.version}</version>
<classifier>darwin-arm64</classifier>
</dependency>
<!-- ... etc for each target platform -->
</dependencies>| Concern | How it's handled |
|---|---|
| No resource path collisions | Each platform has its own subdirectory (native/<classifier>/) |
| Extraction only happens once | Cached to ~/.copilot/runtime-cache/<version>/<classifier>/ |
| Works without uber-jar too | Same getResourceAsStream call — classloader finds it in the separate JAR |
| Subset selection | Consumer declares only the classifiers they need; missing platforms get a clear error at runtime |
| JNA loading | NativeLibrary.getInstance(path) loads from an absolute filesystem path after extraction — no JNA platform-detection magic needed |
The pattern is identical to how DJL's LibUtils.loadLibrary() works — detect platform, construct resource path, extract if needed, load via absolute path.