Skip to content

Commit d7cd682

Browse files
CopilotedburnsCopilot
committed
[Java] Embed Rust CLI runtime 4.4: JNA binding interface and implementation (#2230)
* Initial plan * feat(java): add JNA binding interface and implementation (task 4.4) - Add JNA 5.19.1 as optional dependency to java/sdk/pom.xml with jna.version property for deliberate upgrades - Create OutboundCallback.java: JNA Callback interface for native-to-Java outbound data delivery - Create NativeBinding.java: interface abstraction for the 5 copilot_runtime_* C ABI entry points - Create JnaNativeBinding.java: JNA implementation with static singleton (library-never-unloads pattern), duplicate path guard, and active-callback AtomicInteger tracking - Create JnaNativeBindingTest.java: 24 unit tests covering delegation, loading, duplicate guard, and callback behavior using the spike-3-4 test native library - Update module-info.java: requires static com.sun.jna, opens com.github.copilot.ffi to com.sun.jna Co-authored-by: edburns <75821+edburns@users.noreply.github.com> * fix: address Copilot code review findings for JNA binding - Fix callback GC: store tracked callbacks per connection in a ConcurrentHashMap, removed on connectionClose (comment #3706039757) - Fix boolean ABI mismatch: Rust bool is 1 byte, JNA maps Java boolean as 32-bit int. Changed CopilotRuntimeLibrary to return byte, convert to boolean in delegation methods (comment #3706039823) - Wrap UnsatisfiedLinkError in IllegalStateException per error contract (comment #3706039855) - Replace silent return with assumeTrue for native lib tests so skips are visible in CI reports (comment #3706039896) - Rewrite activeCallbackCount test to exercise through JnaNativeBinding and assert binding.activeCallbacks (comment #3706039935) - Add ABI name documentation to CallbackTestLib, fix byte return types in test interface (comment #3706039968) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: edburns <75821+edburns@users.noreply.github.com> Co-authored-by: Ed Burns <edburns@microsoft.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent d1440c6 commit d7cd682

6 files changed

Lines changed: 962 additions & 0 deletions

File tree

java/sdk/pom.xml

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,8 @@
6262
<surefire.jvm.args />
6363
<!-- Override parent: the sdk module IS published to Maven Central. -->
6464
<maven.deploy.skip>false</maven.deploy.skip>
65+
<!-- JNA version — pin deliberately; a JNA upgrade must rerun the callback spike. -->
66+
<jna.version>5.19.1</jna.version>
6567
</properties>
6668

6769
<dependencies>
@@ -90,6 +92,18 @@
9092
<scope>provided</scope>
9193
</dependency>
9294

95+
<!--
96+
JNA — required only when using InProcess transport. Optional so that
97+
subprocess-only consumers do not receive JNA transitively.
98+
Pin the version; a JNA upgrade must rerun the callback spike.
99+
-->
100+
<dependency>
101+
<groupId>net.java.dev.jna</groupId>
102+
<artifactId>jna</artifactId>
103+
<version>${jna.version}</version>
104+
<optional>true</optional>
105+
</dependency>
106+
93107
<!-- Test dependencies -->
94108
<dependency>
95109
<groupId>org.junit.jupiter</groupId>
Lines changed: 248 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,248 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* Copyright (c) Microsoft Corporation. All rights reserved.
3+
*--------------------------------------------------------------------------------------------*/
4+
5+
package com.github.copilot.ffi;
6+
7+
import com.sun.jna.Library;
8+
import com.sun.jna.Native;
9+
import com.sun.jna.Pointer;
10+
11+
import java.nio.file.Path;
12+
import java.util.Map;
13+
import java.util.concurrent.ConcurrentHashMap;
14+
import java.util.concurrent.atomic.AtomicInteger;
15+
import java.util.logging.Logger;
16+
17+
/**
18+
* JNA-backed implementation of {@link NativeBinding}.
19+
*
20+
* <p>
21+
* Loads the {@code runtime.node} native library by absolute path and delegates
22+
* each {@link NativeBinding} method to the corresponding
23+
* {@code copilot_runtime_*} C ABI export.
24+
*
25+
* <h2>Library-never-unloads pattern</h2>
26+
* <p>
27+
* The loaded JNA library handle is held in a {@code static} field and is never
28+
* released. Native worker threads spawned by the runtime outlive any individual
29+
* {@code FfiRuntimeHost} instance; unloading the library while those threads
30+
* are active would cause a crash. This mirrors the Rust runtime's own
31+
* {@code OnceLock<Mutex<HashMap<PathBuf, &'static Library>>>} pattern.
32+
*
33+
* <h2>Duplicate-load guard</h2>
34+
* <p>
35+
* Loading a library from a <em>different</em> absolute path in the same JVM
36+
* process is rejected with {@link IllegalStateException}. Loading from the
37+
* <em>same</em> path more than once is silently accepted.
38+
*
39+
* <h2>Active-callback tracking</h2>
40+
* <p>
41+
* The {@link #activeCallbacks} counter is incremented when the native runtime
42+
* enters the outbound callback and decremented when the callback returns.
43+
* Callers (e.g. {@code FfiRuntimeHost}) must drain this counter to zero before
44+
* calling {@link #connectionClose} or {@link #hostShutdown}.
45+
*
46+
* <h2>GraalVM Native Image</h2>
47+
* <p>
48+
* JNA callback upcalls are not supported under GraalVM Native Image. InProcess
49+
* transport is not available in native-image executables; use subprocess
50+
* transport instead.
51+
*/
52+
final class JnaNativeBinding implements NativeBinding {
53+
54+
private static final Logger LOG = Logger.getLogger(JnaNativeBinding.class.getName());
55+
56+
/**
57+
* JNA inner interface mapping the five {@code copilot_runtime_*} C ABI exports.
58+
*/
59+
interface CopilotRuntimeLibrary extends Library {
60+
/** Corresponds to {@code copilot_runtime_host_start}. */
61+
int copilot_runtime_host_start(byte[] argvJson, int argvJsonLen, byte[] envJson, int envJsonLen);
62+
63+
/**
64+
* Corresponds to {@code copilot_runtime_host_shutdown}.
65+
*
66+
* <p>
67+
* Returns {@code byte} (not Java {@code boolean}) because the Rust ABI exports
68+
* a one-byte {@code bool}. JNA maps Java {@code boolean} as a 32-bit C
69+
* {@code int}, which would read three extra bytes.
70+
*/
71+
byte copilot_runtime_host_shutdown(int serverId);
72+
73+
/** Corresponds to {@code copilot_runtime_connection_open}. */
74+
int copilot_runtime_connection_open(int serverId, OutboundCallback callback, Pointer userData, byte[] extSource,
75+
int extSourceLen, byte[] extName, int extNameLen, byte[] connToken, int connTokenLen);
76+
77+
/**
78+
* Corresponds to {@code copilot_runtime_connection_write}.
79+
*
80+
* @see #copilot_runtime_host_shutdown for why this returns {@code byte}
81+
*/
82+
byte copilot_runtime_connection_write(int connectionId, byte[] data, int dataLen);
83+
84+
/**
85+
* Corresponds to {@code copilot_runtime_connection_close}.
86+
*
87+
* @see #copilot_runtime_host_shutdown for why this returns {@code byte}
88+
*/
89+
byte copilot_runtime_connection_close(int connectionId);
90+
}
91+
92+
// -------------------------------------------------------------------------
93+
// Process-wide singleton — never unloaded
94+
// -------------------------------------------------------------------------
95+
96+
private static final Object LOAD_LOCK = new Object();
97+
98+
/** Absolute path of the library that was first loaded into this JVM process. */
99+
private static volatile Path loadedPath;
100+
101+
/** The loaded JNA library interface. Never released after first set. */
102+
private static volatile CopilotRuntimeLibrary loadedLib;
103+
104+
// -------------------------------------------------------------------------
105+
// Instance state
106+
// -------------------------------------------------------------------------
107+
108+
/**
109+
* The library interface used by this instance for all delegated calls.
110+
*
111+
* <p>
112+
* For the production path ({@link #JnaNativeBinding(Path)}), this is always the
113+
* same object as {@link #loadedLib} (the static singleton). For the test path
114+
* ({@link #JnaNativeBinding(CopilotRuntimeLibrary)}), this may be a stub or
115+
* mock without modifying the static singleton.
116+
*/
117+
private final CopilotRuntimeLibrary lib;
118+
119+
/**
120+
* Count of callbacks currently executing on native threads. Must reach zero
121+
* before {@link #connectionClose} or {@link #hostShutdown} is called.
122+
*/
123+
final AtomicInteger activeCallbacks = new AtomicInteger(0);
124+
125+
/**
126+
* Tracked callback wrappers keyed by connection handle. Prevents GC of the JNA
127+
* callback function pointer while native code still holds it.
128+
*/
129+
private final Map<Integer, OutboundCallback> trackedCallbacks = new ConcurrentHashMap<>();
130+
131+
// -------------------------------------------------------------------------
132+
// Constructors
133+
// -------------------------------------------------------------------------
134+
135+
/**
136+
* Loads (or re-uses) the native library at the given absolute path.
137+
*
138+
* @param libraryPath
139+
* absolute path to the {@code runtime.node} native library
140+
* @throws IllegalStateException
141+
* if a <em>different</em> library path has already been loaded in
142+
* this JVM process
143+
*/
144+
JnaNativeBinding(Path libraryPath) {
145+
Path absPath = libraryPath.toAbsolutePath().normalize();
146+
synchronized (LOAD_LOCK) {
147+
if (loadedLib == null) {
148+
LOG.fine(() -> "Loading native library from: " + absPath);
149+
try {
150+
loadedLib = Native.load(absPath.toString(), CopilotRuntimeLibrary.class);
151+
} catch (UnsatisfiedLinkError e) {
152+
throw new IllegalStateException("Failed to load native library from '" + absPath + "'", e);
153+
}
154+
loadedPath = absPath;
155+
LOG.fine(() -> "Native library loaded: " + absPath);
156+
} else if (!absPath.equals(loadedPath)) {
157+
throw new IllegalStateException("An in-process FFI runtime library is already loaded from '"
158+
+ loadedPath + "'; loading a different library from '" + absPath
159+
+ "' in the same process is not supported.");
160+
}
161+
}
162+
this.lib = loadedLib;
163+
}
164+
165+
/**
166+
* Testing constructor — accepts a pre-built {@link CopilotRuntimeLibrary}
167+
* directly, bypassing disk I/O and the static singleton guard.
168+
*
169+
* <p>
170+
* This constructor is package-private and intended solely for unit tests.
171+
*
172+
* @param library
173+
* a {@link CopilotRuntimeLibrary} stub or mock for testing
174+
*/
175+
JnaNativeBinding(CopilotRuntimeLibrary library) {
176+
// Testing seam — skip the static singleton guard.
177+
this.lib = library;
178+
}
179+
180+
// -------------------------------------------------------------------------
181+
// NativeBinding delegation
182+
// -------------------------------------------------------------------------
183+
184+
@Override
185+
public int hostStart(byte[] argvJson, int argvJsonLen, byte[] envJson, int envJsonLen) {
186+
return lib.copilot_runtime_host_start(argvJson, argvJsonLen, envJson, envJsonLen);
187+
}
188+
189+
@Override
190+
public boolean hostShutdown(int serverId) {
191+
return lib.copilot_runtime_host_shutdown(serverId) != 0;
192+
}
193+
194+
@Override
195+
public int connectionOpen(int serverId, OutboundCallback callback, Pointer userData, byte[] extSource,
196+
int extSourceLen, byte[] extName, int extNameLen, byte[] connToken, int connTokenLen) {
197+
// Wrap the caller's callback to maintain active-callback tracking.
198+
OutboundCallback tracked = (ud, data, len) -> {
199+
activeCallbacks.incrementAndGet();
200+
try {
201+
callback.invoke(ud, data, len);
202+
} finally {
203+
activeCallbacks.decrementAndGet();
204+
}
205+
};
206+
int connectionId = lib.copilot_runtime_connection_open(serverId, tracked, userData, extSource, extSourceLen,
207+
extName, extNameLen, connToken, connTokenLen);
208+
if (connectionId != 0) {
209+
// Hold a strong reference to prevent GC of the JNA function pointer.
210+
trackedCallbacks.put(connectionId, tracked);
211+
}
212+
return connectionId;
213+
}
214+
215+
@Override
216+
public boolean connectionWrite(int connectionId, byte[] data, int dataLen) {
217+
return lib.copilot_runtime_connection_write(connectionId, data, dataLen) != 0;
218+
}
219+
220+
@Override
221+
public boolean connectionClose(int connectionId) {
222+
try {
223+
return lib.copilot_runtime_connection_close(connectionId) != 0;
224+
} finally {
225+
trackedCallbacks.remove(connectionId);
226+
}
227+
}
228+
229+
// -------------------------------------------------------------------------
230+
// Testing support
231+
// -------------------------------------------------------------------------
232+
233+
/**
234+
* Resets the process-wide static state for unit tests.
235+
*
236+
* <p>
237+
* <strong>Must only be called from test code.</strong> Resets
238+
* {@link #loadedPath} and {@link #loadedLib} so that a subsequent
239+
* {@link #JnaNativeBinding(Path)} call can load a different library. In
240+
* production, the library is never unloaded.
241+
*/
242+
static void resetForTesting() {
243+
synchronized (LOAD_LOCK) {
244+
loadedPath = null;
245+
loadedLib = null;
246+
}
247+
}
248+
}

0 commit comments

Comments
 (0)