diff --git a/java/sdk/pom.xml b/java/sdk/pom.xml index e24c2ebf2e..53b15e3f68 100644 --- a/java/sdk/pom.xml +++ b/java/sdk/pom.xml @@ -62,6 +62,8 @@ false + + 5.19.1 @@ -90,6 +92,18 @@ provided + + + net.java.dev.jna + jna + ${jna.version} + true + + org.junit.jupiter diff --git a/java/sdk/src/main/java/com/github/copilot/ffi/JnaNativeBinding.java b/java/sdk/src/main/java/com/github/copilot/ffi/JnaNativeBinding.java new file mode 100644 index 0000000000..af3930bc99 --- /dev/null +++ b/java/sdk/src/main/java/com/github/copilot/ffi/JnaNativeBinding.java @@ -0,0 +1,248 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot.ffi; + +import com.sun.jna.Library; +import com.sun.jna.Native; +import com.sun.jna.Pointer; + +import java.nio.file.Path; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.logging.Logger; + +/** + * JNA-backed implementation of {@link NativeBinding}. + * + *

+ * Loads the {@code runtime.node} native library by absolute path and delegates + * each {@link NativeBinding} method to the corresponding + * {@code copilot_runtime_*} C ABI export. + * + *

Library-never-unloads pattern

+ *

+ * The loaded JNA library handle is held in a {@code static} field and is never + * released. Native worker threads spawned by the runtime outlive any individual + * {@code FfiRuntimeHost} instance; unloading the library while those threads + * are active would cause a crash. This mirrors the Rust runtime's own + * {@code OnceLock>>} pattern. + * + *

Duplicate-load guard

+ *

+ * Loading a library from a different absolute path in the same JVM + * process is rejected with {@link IllegalStateException}. Loading from the + * same path more than once is silently accepted. + * + *

Active-callback tracking

+ *

+ * The {@link #activeCallbacks} counter is incremented when the native runtime + * enters the outbound callback and decremented when the callback returns. + * Callers (e.g. {@code FfiRuntimeHost}) must drain this counter to zero before + * calling {@link #connectionClose} or {@link #hostShutdown}. + * + *

GraalVM Native Image

+ *

+ * JNA callback upcalls are not supported under GraalVM Native Image. InProcess + * transport is not available in native-image executables; use subprocess + * transport instead. + */ +final class JnaNativeBinding implements NativeBinding { + + private static final Logger LOG = Logger.getLogger(JnaNativeBinding.class.getName()); + + /** + * JNA inner interface mapping the five {@code copilot_runtime_*} C ABI exports. + */ + interface CopilotRuntimeLibrary extends Library { + /** Corresponds to {@code copilot_runtime_host_start}. */ + int copilot_runtime_host_start(byte[] argvJson, int argvJsonLen, byte[] envJson, int envJsonLen); + + /** + * Corresponds to {@code copilot_runtime_host_shutdown}. + * + *

+ * Returns {@code byte} (not Java {@code boolean}) because the Rust ABI exports + * a one-byte {@code bool}. JNA maps Java {@code boolean} as a 32-bit C + * {@code int}, which would read three extra bytes. + */ + byte copilot_runtime_host_shutdown(int serverId); + + /** Corresponds to {@code copilot_runtime_connection_open}. */ + int copilot_runtime_connection_open(int serverId, OutboundCallback callback, Pointer userData, byte[] extSource, + int extSourceLen, byte[] extName, int extNameLen, byte[] connToken, int connTokenLen); + + /** + * Corresponds to {@code copilot_runtime_connection_write}. + * + * @see #copilot_runtime_host_shutdown for why this returns {@code byte} + */ + byte copilot_runtime_connection_write(int connectionId, byte[] data, int dataLen); + + /** + * Corresponds to {@code copilot_runtime_connection_close}. + * + * @see #copilot_runtime_host_shutdown for why this returns {@code byte} + */ + byte copilot_runtime_connection_close(int connectionId); + } + + // ------------------------------------------------------------------------- + // Process-wide singleton — never unloaded + // ------------------------------------------------------------------------- + + private static final Object LOAD_LOCK = new Object(); + + /** Absolute path of the library that was first loaded into this JVM process. */ + private static volatile Path loadedPath; + + /** The loaded JNA library interface. Never released after first set. */ + private static volatile CopilotRuntimeLibrary loadedLib; + + // ------------------------------------------------------------------------- + // Instance state + // ------------------------------------------------------------------------- + + /** + * The library interface used by this instance for all delegated calls. + * + *

+ * For the production path ({@link #JnaNativeBinding(Path)}), this is always the + * same object as {@link #loadedLib} (the static singleton). For the test path + * ({@link #JnaNativeBinding(CopilotRuntimeLibrary)}), this may be a stub or + * mock without modifying the static singleton. + */ + private final CopilotRuntimeLibrary lib; + + /** + * Count of callbacks currently executing on native threads. Must reach zero + * before {@link #connectionClose} or {@link #hostShutdown} is called. + */ + final AtomicInteger activeCallbacks = new AtomicInteger(0); + + /** + * Tracked callback wrappers keyed by connection handle. Prevents GC of the JNA + * callback function pointer while native code still holds it. + */ + private final Map trackedCallbacks = new ConcurrentHashMap<>(); + + // ------------------------------------------------------------------------- + // Constructors + // ------------------------------------------------------------------------- + + /** + * Loads (or re-uses) the native library at the given absolute path. + * + * @param libraryPath + * absolute path to the {@code runtime.node} native library + * @throws IllegalStateException + * if a different library path has already been loaded in + * this JVM process + */ + JnaNativeBinding(Path libraryPath) { + Path absPath = libraryPath.toAbsolutePath().normalize(); + synchronized (LOAD_LOCK) { + if (loadedLib == null) { + LOG.fine(() -> "Loading native library from: " + absPath); + try { + loadedLib = Native.load(absPath.toString(), CopilotRuntimeLibrary.class); + } catch (UnsatisfiedLinkError e) { + throw new IllegalStateException("Failed to load native library from '" + absPath + "'", e); + } + loadedPath = absPath; + LOG.fine(() -> "Native library loaded: " + absPath); + } else if (!absPath.equals(loadedPath)) { + throw new IllegalStateException("An in-process FFI runtime library is already loaded from '" + + loadedPath + "'; loading a different library from '" + absPath + + "' in the same process is not supported."); + } + } + this.lib = loadedLib; + } + + /** + * Testing constructor — accepts a pre-built {@link CopilotRuntimeLibrary} + * directly, bypassing disk I/O and the static singleton guard. + * + *

+ * This constructor is package-private and intended solely for unit tests. + * + * @param library + * a {@link CopilotRuntimeLibrary} stub or mock for testing + */ + JnaNativeBinding(CopilotRuntimeLibrary library) { + // Testing seam — skip the static singleton guard. + this.lib = library; + } + + // ------------------------------------------------------------------------- + // NativeBinding delegation + // ------------------------------------------------------------------------- + + @Override + public int hostStart(byte[] argvJson, int argvJsonLen, byte[] envJson, int envJsonLen) { + return lib.copilot_runtime_host_start(argvJson, argvJsonLen, envJson, envJsonLen); + } + + @Override + public boolean hostShutdown(int serverId) { + return lib.copilot_runtime_host_shutdown(serverId) != 0; + } + + @Override + public int connectionOpen(int serverId, OutboundCallback callback, Pointer userData, byte[] extSource, + int extSourceLen, byte[] extName, int extNameLen, byte[] connToken, int connTokenLen) { + // Wrap the caller's callback to maintain active-callback tracking. + OutboundCallback tracked = (ud, data, len) -> { + activeCallbacks.incrementAndGet(); + try { + callback.invoke(ud, data, len); + } finally { + activeCallbacks.decrementAndGet(); + } + }; + int connectionId = lib.copilot_runtime_connection_open(serverId, tracked, userData, extSource, extSourceLen, + extName, extNameLen, connToken, connTokenLen); + if (connectionId != 0) { + // Hold a strong reference to prevent GC of the JNA function pointer. + trackedCallbacks.put(connectionId, tracked); + } + return connectionId; + } + + @Override + public boolean connectionWrite(int connectionId, byte[] data, int dataLen) { + return lib.copilot_runtime_connection_write(connectionId, data, dataLen) != 0; + } + + @Override + public boolean connectionClose(int connectionId) { + try { + return lib.copilot_runtime_connection_close(connectionId) != 0; + } finally { + trackedCallbacks.remove(connectionId); + } + } + + // ------------------------------------------------------------------------- + // Testing support + // ------------------------------------------------------------------------- + + /** + * Resets the process-wide static state for unit tests. + * + *

+ * Must only be called from test code. Resets + * {@link #loadedPath} and {@link #loadedLib} so that a subsequent + * {@link #JnaNativeBinding(Path)} call can load a different library. In + * production, the library is never unloaded. + */ + static void resetForTesting() { + synchronized (LOAD_LOCK) { + loadedPath = null; + loadedLib = null; + } + } +} diff --git a/java/sdk/src/main/java/com/github/copilot/ffi/NativeBinding.java b/java/sdk/src/main/java/com/github/copilot/ffi/NativeBinding.java new file mode 100644 index 0000000000..3aa8ca9e4d --- /dev/null +++ b/java/sdk/src/main/java/com/github/copilot/ffi/NativeBinding.java @@ -0,0 +1,131 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot.ffi; + +import com.sun.jna.Pointer; + +/** + * Internal abstraction over the Copilot runtime C ABI. + * + *

+ * Defines the five {@code extern "C"} entry points exposed by the native + * {@code runtime.node} library. The JNA-backed implementation + * ({@link JnaNativeBinding}) delegates to these through JNA. A future FFM + * implementation may be substituted via the multi-release JAR mechanism without + * changing callers. + * + *

+ * All classes in {@code com.github.copilot.ffi} are internal; consumers must + * not reference them directly. + * + *

C ABI entry points

+ *
    + *
  • {@code copilot_runtime_host_start} — start the runtime host
  • + *
  • {@code copilot_runtime_host_shutdown} — shut down the runtime host
  • + *
  • {@code copilot_runtime_connection_open} — open a bidirectional + * connection
  • + *
  • {@code copilot_runtime_connection_write} — write a JSON-RPC frame to the + * runtime
  • + *
  • {@code copilot_runtime_connection_close} — close a connection
  • + *
+ * + *

Wire format

+ *

+ * All frames use LSP {@code Content-Length} header framing, identical to the + * stdio transport. No special encoding or decoding is needed at the FFI + * boundary. + */ +interface NativeBinding { + + /** + * Starts the runtime host. + * + *

+ * Blocks for up to ~30 s while the worker boots and connects back. Must not be + * called on an async/reactive executor thread. + * + * @param argvJson + * UTF-8 JSON array of strings: the entrypoint and required flags + * @param argvJsonLen + * byte length of {@code argvJson} + * @param envJson + * UTF-8 JSON object of environment overrides, or {@code null} when + * empty + * @param envJsonLen + * byte length of {@code envJson}, or {@code 0} when {@code envJson} + * is null + * @return server handle ({@code 0} on failure) + */ + int hostStart(byte[] argvJson, int argvJsonLen, byte[] envJson, int envJsonLen); + + /** + * Shuts down the runtime host. + * + * @param serverId + * non-zero server handle returned by {@link #hostStart} + * @return {@code true} on success + */ + boolean hostShutdown(int serverId); + + /** + * Opens a bidirectional connection and registers the outbound data callback. + * + *

+ * The {@code extSource}, {@code extName}, and {@code connToken} parameters are + * reserved extension points. All current SDK implementations pass + * {@code null}/0 for all three. + * + * @param serverId + * non-zero server handle returned by {@link #hostStart} + * @param callback + * JNA callback invoked by the runtime on native threads when + * outbound data is available; must be held as a strong reference by + * the caller + * @param userData + * opaque cookie passed back to {@code callback} unchanged; pass + * {@link Pointer#NULL} + * @param extSource + * reserved; pass {@code null} + * @param extSourceLen + * byte length of {@code extSource}; pass {@code 0} + * @param extName + * reserved; pass {@code null} + * @param extNameLen + * byte length of {@code extName}; pass {@code 0} + * @param connToken + * reserved; pass {@code null} + * @param connTokenLen + * byte length of {@code connToken}; pass {@code 0} + * @return connection handle ({@code 0} on failure) + */ + int connectionOpen(int serverId, OutboundCallback callback, Pointer userData, byte[] extSource, int extSourceLen, + byte[] extName, int extNameLen, byte[] connToken, int connTokenLen); + + /** + * Writes a JSON-RPC frame to the runtime. + * + *

+ * The native side copies the buffer synchronously before returning; the byte + * array does not need to survive past this call. + * + * @param connectionId + * non-zero connection handle returned by {@link #connectionOpen} + * @param data + * frame bytes + * @param dataLen + * byte length of {@code data} + * @return {@code true} on success + */ + boolean connectionWrite(int connectionId, byte[] data, int dataLen); + + /** + * Closes a connection. + * + * @param connectionId + * non-zero connection handle returned by {@link #connectionOpen} + * @return {@code true} on success + */ + boolean connectionClose(int connectionId); +} diff --git a/java/sdk/src/main/java/com/github/copilot/ffi/OutboundCallback.java b/java/sdk/src/main/java/com/github/copilot/ffi/OutboundCallback.java new file mode 100644 index 0000000000..6f5c319224 --- /dev/null +++ b/java/sdk/src/main/java/com/github/copilot/ffi/OutboundCallback.java @@ -0,0 +1,46 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot.ffi; + +import com.sun.jna.Callback; +import com.sun.jna.Pointer; + +/** + * JNA callback interface for the runtime-to-Java outbound data path. + * + *

+ * The native runtime invokes this callback on a native thread when data is + * ready to be delivered to the Java side. JNA automatically attaches the native + * thread to the JVM before dispatching the callback. + * + *

+ * Buffer lifetime: The {@code data} pointer is only valid for + * the duration of the callback invocation. Implementations must copy the bytes + * out (e.g. {@code data.getByteArray(0, len)}) before returning. + * + *

+ * GC protection: Instances must be held as strong-reference + * fields for as long as native code may invoke the callback. If the instance is + * garbage-collected, the function pointer becomes dangling and the JVM will + * crash. + */ +@FunctionalInterface +interface OutboundCallback extends Callback { + + /** + * Invoked by the native runtime when outbound data is available. + * + * @param userData + * opaque cookie passed through unchanged from + * {@code copilot_runtime_connection_open}; always + * {@code Pointer.NULL} in this SDK + * @param data + * pointer to the outbound byte buffer; valid only for the duration + * of this invocation + * @param len + * byte length of the buffer pointed to by {@code data} + */ + void invoke(Pointer userData, Pointer data, int len); +} diff --git a/java/sdk/src/main/java/module-info.java b/java/sdk/src/main/java/module-info.java index 38bc1f93d5..8bc2dbd55c 100644 --- a/java/sdk/src/main/java/module-info.java +++ b/java/sdk/src/main/java/module-info.java @@ -12,6 +12,7 @@ requires com.fasterxml.jackson.datatype.jsr310; requires static com.github.spotbugs.annotations; requires static java.compiler; + requires static com.sun.jna; requires java.net.http; requires java.logging; @@ -25,6 +26,7 @@ opens com.github.copilot.generated to com.fasterxml.jackson.databind; opens com.github.copilot.generated.rpc to com.fasterxml.jackson.databind; opens com.github.copilot.rpc to com.fasterxml.jackson.databind; + opens com.github.copilot.ffi to com.sun.jna; provides javax.annotation.processing.Processor with com.github.copilot.CopilotExperimentalProcessor, com.github.copilot.tool.CopilotToolProcessor; diff --git a/java/sdk/src/test/java/com/github/copilot/ffi/JnaNativeBindingTest.java b/java/sdk/src/test/java/com/github/copilot/ffi/JnaNativeBindingTest.java new file mode 100644 index 0000000000..9f0f939eee --- /dev/null +++ b/java/sdk/src/test/java/com/github/copilot/ffi/JnaNativeBindingTest.java @@ -0,0 +1,521 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot.ffi; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +import com.sun.jna.Library; +import com.sun.jna.Native; +import com.sun.jna.Pointer; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** + * Unit tests for {@link JnaNativeBinding} using the spike-3-4 test native + * library ({@code libcallback_test}). + * + *

+ * The spike test library exports simplified versions of the runtime ABI + * functions: {@code host_start}, {@code host_shutdown}, + * {@code connection_open}, {@code connection_write}, and + * {@code connection_close}. Callback tests use this library through a + * test-specific JNA interface, while loading and guard tests exercise + * {@link JnaNativeBinding} directly. + * + *

+ * Tests that require the native library are conditionally skipped when the + * library is not present (e.g. on an architecture without a pre-built binary). + */ +class JnaNativeBindingTest { + + /** + * System property that points at the absolute path of the test native library. + * + *

+ * Default: the {@code libcallback_test.so} built from the spike-3-4 Rust crate, + * relative to {@code java/sdk/}. + */ + private static final String TEST_LIB_PATH_PROP = "copilot.test.nativelib.path"; + + private static final String SPIKE_LIB_PATH = System.getProperty(TEST_LIB_PATH_PROP, + "../../1917-java-embed-rust-cli-runtime-remove-before-merge" + "/spike-3-4-jna-callback-and-threading" + + "/rust-dll/target/release/libcallback_test.so"); + + // ------------------------------------------------------------------------- + // Test-specific JNA interface for the spike-3-4 test library + // ------------------------------------------------------------------------- + + /** + * JNA interface for the simplified test library. Maps Java names to the + * snake_case exports of {@code libcallback_test}. + * + *

+ * Note: This test library exports simplified names ({@code host_start}, + * {@code connection_open}, etc.) rather than the production + * {@code copilot_runtime_*} names. Production symbol resolution is validated + * lazily by JNA when each method is first called through + * {@link JnaNativeBinding.CopilotRuntimeLibrary}. + */ + interface CallbackTestLib extends Library { + /** Simulates {@code copilot_runtime_host_start}; always returns 42. */ + int host_start(); + + /** + * Simulates {@code copilot_runtime_host_shutdown}; always returns nonzero. + * Returns {@code byte} to match the Rust ABI one-byte {@code bool}. + */ + byte host_shutdown(int serverHandle); + + /** + * Simulates {@code copilot_runtime_connection_open}. Spawns a native thread + * that invokes {@code callback} {@code burstCount} times. Returns 7. + */ + int connection_open(int serverHandle, OutboundCallback callback, Pointer userData, int burstCount); + + /** + * Simulates {@code copilot_runtime_connection_write}; always returns nonzero. + * Returns {@code byte} to match the Rust ABI one-byte {@code bool}. + */ + byte connection_write(int connectionHandle, byte[] data, int len); + + /** + * Simulates {@code copilot_runtime_connection_close}; always returns nonzero. + * Returns {@code byte} to match the Rust ABI one-byte {@code bool}. + */ + byte connection_close(int connectionHandle); + } + + // ------------------------------------------------------------------------- + // Stub CopilotRuntimeLibrary for delegation tests + // ------------------------------------------------------------------------- + + /** + * Minimal stub for testing {@link JnaNativeBinding} delegation without disk + * I/O. + */ + private static class StubRuntimeLibrary implements JnaNativeBinding.CopilotRuntimeLibrary { + int hostStartReturn = 1; + byte hostShutdownReturn = 1; + int connectionOpenReturn = 1; + byte connectionWriteReturn = 1; + byte connectionCloseReturn = 1; + + byte[] lastArgvJson; + int lastArgvJsonLen; + int lastServerId; + int lastConnectionId; + OutboundCallback lastCallback; + + @Override + public int copilot_runtime_host_start(byte[] argvJson, int argvJsonLen, byte[] envJson, int envJsonLen) { + lastArgvJson = argvJson; + lastArgvJsonLen = argvJsonLen; + return hostStartReturn; + } + + @Override + public byte copilot_runtime_host_shutdown(int serverId) { + lastServerId = serverId; + return hostShutdownReturn; + } + + @Override + public int copilot_runtime_connection_open(int serverId, OutboundCallback callback, Pointer userData, + byte[] extSource, int extSourceLen, byte[] extName, int extNameLen, byte[] connToken, + int connTokenLen) { + lastServerId = serverId; + lastCallback = callback; + return connectionOpenReturn; + } + + @Override + public byte copilot_runtime_connection_write(int connectionId, byte[] data, int dataLen) { + lastConnectionId = connectionId; + return connectionWriteReturn; + } + + @Override + public byte copilot_runtime_connection_close(int connectionId) { + lastConnectionId = connectionId; + return connectionCloseReturn; + } + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + private static boolean testLibExists() { + return Files.isRegularFile(testLibAbsPath()); + } + + private static Path testLibAbsPath() { + return Path.of(SPIKE_LIB_PATH).toAbsolutePath().normalize(); + } + + private static CallbackTestLib loadTestLib() { + return Native.load(testLibAbsPath().toString(), CallbackTestLib.class); + } + + @AfterEach + void resetStaticState() { + JnaNativeBinding.resetForTesting(); + } + + // ========================================================================= + // Delegation via testing constructor (stub — no disk I/O) + // ========================================================================= + + @Test + void hostStartDelegatesToLibraryAndReturnsHandle() { + StubRuntimeLibrary stub = new StubRuntimeLibrary(); + stub.hostStartReturn = 77; + JnaNativeBinding binding = new JnaNativeBinding(stub); + + byte[] argv = "[\"copilot\"]".getBytes(StandardCharsets.UTF_8); + int result = binding.hostStart(argv, argv.length, null, 0); + + assertEquals(77, result, "hostStart should return the stub's configured value"); + assertEquals(argv, stub.lastArgvJson, "argv bytes should be passed through unchanged"); + assertEquals(argv.length, stub.lastArgvJsonLen); + } + + @Test + void hostStartReturnsZeroOnFailure() { + StubRuntimeLibrary stub = new StubRuntimeLibrary(); + stub.hostStartReturn = 0; + JnaNativeBinding binding = new JnaNativeBinding(stub); + + byte[] argv = "[\"copilot\"]".getBytes(StandardCharsets.UTF_8); + assertEquals(0, binding.hostStart(argv, argv.length, null, 0), "hostStart must return 0 to signal failure"); + } + + @Test + void hostShutdownDelegatesToLibrary() { + StubRuntimeLibrary stub = new StubRuntimeLibrary(); + stub.hostShutdownReturn = 1; + JnaNativeBinding binding = new JnaNativeBinding(stub); + + assertTrue(binding.hostShutdown(42)); + assertEquals(42, stub.lastServerId); + } + + @Test + void hostShutdownReturnsFalseOnFailure() { + StubRuntimeLibrary stub = new StubRuntimeLibrary(); + stub.hostShutdownReturn = 0; + JnaNativeBinding binding = new JnaNativeBinding(stub); + assertFalse(binding.hostShutdown(1)); + } + + @Test + void connectionOpenDelegatesToLibrary() { + StubRuntimeLibrary stub = new StubRuntimeLibrary(); + stub.connectionOpenReturn = 55; + JnaNativeBinding binding = new JnaNativeBinding(stub); + + OutboundCallback noop = (ud, data, len) -> { + }; + int connId = binding.connectionOpen(42, noop, Pointer.NULL, null, 0, null, 0, null, 0); + + assertEquals(55, connId, "connectionOpen should return the stub's configured handle"); + assertEquals(42, stub.lastServerId); + } + + @Test + void connectionOpenReturnsZeroOnFailure() { + StubRuntimeLibrary stub = new StubRuntimeLibrary(); + stub.connectionOpenReturn = 0; + JnaNativeBinding binding = new JnaNativeBinding(stub); + + OutboundCallback noop = (ud, data, len) -> { + }; + assertEquals(0, binding.connectionOpen(1, noop, Pointer.NULL, null, 0, null, 0, null, 0), + "connectionOpen must return 0 to signal failure"); + } + + @Test + void connectionWriteDelegatesToLibrary() { + StubRuntimeLibrary stub = new StubRuntimeLibrary(); + stub.connectionWriteReturn = 1; + JnaNativeBinding binding = new JnaNativeBinding(stub); + + byte[] data = "hello".getBytes(StandardCharsets.UTF_8); + assertTrue(binding.connectionWrite(7, data, data.length)); + assertEquals(7, stub.lastConnectionId); + } + + @Test + void connectionWriteReturnsFalseOnFailure() { + StubRuntimeLibrary stub = new StubRuntimeLibrary(); + stub.connectionWriteReturn = 0; + JnaNativeBinding binding = new JnaNativeBinding(stub); + + byte[] data = "x".getBytes(StandardCharsets.UTF_8); + assertFalse(binding.connectionWrite(1, data, data.length), + "connectionWrite must propagate false return from the library"); + } + + @Test + void connectionCloseDelegatesToLibrary() { + StubRuntimeLibrary stub = new StubRuntimeLibrary(); + stub.connectionCloseReturn = 1; + JnaNativeBinding binding = new JnaNativeBinding(stub); + assertTrue(binding.connectionClose(7)); + assertEquals(7, stub.lastConnectionId); + } + + @Test + void connectionCloseReturnsFalseOnFailure() { + StubRuntimeLibrary stub = new StubRuntimeLibrary(); + stub.connectionCloseReturn = 0; + JnaNativeBinding binding = new JnaNativeBinding(stub); + assertFalse(binding.connectionClose(1)); + } + + @Test + void activeCallbacksStartsAtZero() { + StubRuntimeLibrary stub = new StubRuntimeLibrary(); + JnaNativeBinding binding = new JnaNativeBinding(stub); + assertEquals(0, binding.activeCallbacks.get(), "Active callback counter must start at zero"); + } + + // ========================================================================= + // Library loading — success paths (requires native library on disk) + // ========================================================================= + + @Test + void loadByPathSucceedsWhenLibraryExists() { + assumeTrue(testLibExists(), "Native test library not found at " + SPIKE_LIB_PATH); + JnaNativeBinding binding = new JnaNativeBinding(testLibAbsPath()); + assertNotNull(binding); + } + + @Test + void loadByPathTwiceWithSamePathSucceeds() { + assumeTrue(testLibExists(), "Native test library not found at " + SPIKE_LIB_PATH); + new JnaNativeBinding(testLibAbsPath()); + // Second construction with the same absolute path must not throw. + new JnaNativeBinding(testLibAbsPath()); + } + + @Test + void activeCallbacksStartsAtZeroAfterPathLoad() { + assumeTrue(testLibExists(), "Native test library not found at " + SPIKE_LIB_PATH); + JnaNativeBinding binding = new JnaNativeBinding(testLibAbsPath()); + assertEquals(0, binding.activeCallbacks.get()); + } + + // ========================================================================= + // Duplicate-load guard + // ========================================================================= + + @Test + void loadFromDifferentPathThrowsIllegalState(@TempDir Path tempDir) throws Exception { + assumeTrue(testLibExists(), "Native test library not found at " + SPIKE_LIB_PATH); + Path altPath = tempDir.resolve("libcallback_test_alt.so"); + Files.copy(testLibAbsPath(), altPath); + + new JnaNativeBinding(testLibAbsPath()); + + IllegalStateException ex = assertThrows(IllegalStateException.class, () -> new JnaNativeBinding(altPath)); + + String msg = ex.getMessage(); + assertTrue(msg.contains("already loaded from"), "Diagnostic must mention 'already loaded from', got: " + msg); + assertTrue(msg.contains(testLibAbsPath().toString()), "Diagnostic must contain path A, got: " + msg); + assertTrue(msg.contains(altPath.toString()), "Diagnostic must contain path B, got: " + msg); + } + + @Test + void duplicateLoadDiagnosticMentionsNotSupported(@TempDir Path tempDir) throws Exception { + assumeTrue(testLibExists(), "Native test library not found at " + SPIKE_LIB_PATH); + Path altPath = tempDir.resolve("libcallback_test_b.so"); + Files.copy(testLibAbsPath(), altPath); + + new JnaNativeBinding(testLibAbsPath()); + + IllegalStateException ex = assertThrows(IllegalStateException.class, () -> new JnaNativeBinding(altPath)); + assertTrue(ex.getMessage().contains("not supported"), + "Diagnostic must mention 'not supported', got: " + ex.getMessage()); + } + + @Test + void resetForTestingAllowsReloadFromDifferentPath(@TempDir Path tempDir) throws Exception { + assumeTrue(testLibExists(), "Native test library not found at " + SPIKE_LIB_PATH); + Path altPath = tempDir.resolve("libcallback_test_reset.so"); + Files.copy(testLibAbsPath(), altPath); + + new JnaNativeBinding(testLibAbsPath()); + + JnaNativeBinding.resetForTesting(); + + // After reset, a different path must succeed. + new JnaNativeBinding(altPath); + } + + // ========================================================================= + // Callback invocation via test native library + // ========================================================================= + + @Test + void callbackIsInvokedFromNativeThread() throws Exception { + assumeTrue(testLibExists(), "Native test library not found at " + SPIKE_LIB_PATH); + CallbackTestLib lib = loadTestLib(); + int serverHandle = lib.host_start(); + assertEquals(42, serverHandle, "host_start should return 42"); + + int burstCount = 3; + CountDownLatch latch = new CountDownLatch(burstCount); + AtomicInteger callbackCount = new AtomicInteger(0); + AtomicInteger activeCallbacks = new AtomicInteger(0); + + OutboundCallback callback = (userData, data, len) -> { + activeCallbacks.incrementAndGet(); + try { + callbackCount.incrementAndGet(); + // Copy before returning — pointer only valid during invocation. + byte[] bytes = data.getByteArray(0, len); + assertEquals(len, bytes.length, "Copied byte array length must equal len parameter"); + } finally { + activeCallbacks.decrementAndGet(); + latch.countDown(); + } + }; + + int connHandle = lib.connection_open(serverHandle, callback, Pointer.NULL, burstCount); + assertEquals(7, connHandle, "connection_open should return 7"); + + assertTrue(latch.await(10, TimeUnit.SECONDS), "All callbacks must complete within 10 seconds"); + assertEquals(burstCount, callbackCount.get(), "Callback must be invoked exactly burstCount times"); + assertEquals(0, activeCallbacks.get(), + "Active callback count must return to zero after all callbacks complete"); + } + + @Test + void activeCallbackCountIsIncrementedDuringCallback() { + StubRuntimeLibrary stub = new StubRuntimeLibrary(); + stub.connectionOpenReturn = 99; + JnaNativeBinding binding = new JnaNativeBinding(stub); + + AtomicInteger observedDuringCallback = new AtomicInteger(-1); + + OutboundCallback userCallback = (userData, data, len) -> { + // Observe binding.activeCallbacks while inside the callback + observedDuringCallback.set(binding.activeCallbacks.get()); + }; + + binding.connectionOpen(1, userCallback, Pointer.NULL, null, 0, null, 0, null, 0); + + // The stub captured the tracked wrapper — invoke it to trigger tracking + assertNotNull(stub.lastCallback, "Stub must have captured the tracked callback"); + stub.lastCallback.invoke(Pointer.NULL, Pointer.NULL, 0); + + assertEquals(1, observedDuringCallback.get(), "binding.activeCallbacks must be 1 during callback execution"); + assertEquals(0, binding.activeCallbacks.get(), + "binding.activeCallbacks must return to 0 after callback completes"); + } + + @Test + void callbackDataContainsJsonRpcContent() throws Exception { + assumeTrue(testLibExists(), "Native test library not found at " + SPIKE_LIB_PATH); + CallbackTestLib lib = loadTestLib(); + int serverHandle = lib.host_start(); + + CountDownLatch latch = new CountDownLatch(1); + AtomicReference receivedMessage = new AtomicReference<>(); + + OutboundCallback callback = (userData, data, len) -> { + try { + byte[] bytes = data.getByteArray(0, len); + receivedMessage.set(new String(bytes, StandardCharsets.UTF_8)); + } finally { + latch.countDown(); + } + }; + + lib.connection_open(serverHandle, callback, Pointer.NULL, 1); + + assertTrue(latch.await(10, TimeUnit.SECONDS), "Callback must complete within 10 seconds"); + String msg = receivedMessage.get(); + assertNotNull(msg, "Received message must not be null"); + assertTrue(msg.contains("jsonrpc"), "Callback data should contain JSON-RPC content, got: " + msg); + } + + @Test + void multipleCallbacksDoNotLeakActiveCount() throws Exception { + assumeTrue(testLibExists(), "Native test library not found at " + SPIKE_LIB_PATH); + CallbackTestLib lib = loadTestLib(); + int serverHandle = lib.host_start(); + + int burstCount = 5; + CountDownLatch latch = new CountDownLatch(burstCount); + AtomicInteger activeCallbacks = new AtomicInteger(0); + AtomicInteger maxObservedActive = new AtomicInteger(0); + + OutboundCallback callback = (userData, data, len) -> { + int current = activeCallbacks.incrementAndGet(); + maxObservedActive.updateAndGet(prev -> Math.max(prev, current)); + try { + data.getByteArray(0, len); + } finally { + activeCallbacks.decrementAndGet(); + latch.countDown(); + } + }; + + lib.connection_open(serverHandle, callback, Pointer.NULL, burstCount); + + assertTrue(latch.await(10, TimeUnit.SECONDS), "All callbacks must complete within timeout"); + assertEquals(0, activeCallbacks.get(), "Active count must be 0 after all callbacks complete"); + assertTrue(maxObservedActive.get() >= 1, "At least one callback must have been observed as active"); + } + + @Test + void connectionWriteReturnsTrueForValidHandle() { + assumeTrue(testLibExists(), "Native test library not found at " + SPIKE_LIB_PATH); + CallbackTestLib lib = loadTestLib(); + int serverHandle = lib.host_start(); + int connHandle = lib.connection_open(serverHandle, (ud, data, len) -> { + }, Pointer.NULL, 0); + + byte[] payload = "{\"jsonrpc\":\"2.0\",\"method\":\"ping\"}".getBytes(StandardCharsets.UTF_8); + assertTrue(lib.connection_write(connHandle, payload, payload.length) != 0, + "connection_write should return nonzero for valid data"); + } + + @Test + void connectionCloseReturnsTrueForValidHandle() { + assumeTrue(testLibExists(), "Native test library not found at " + SPIKE_LIB_PATH); + CallbackTestLib lib = loadTestLib(); + int serverHandle = lib.host_start(); + int connHandle = lib.connection_open(serverHandle, (ud, data, len) -> { + }, Pointer.NULL, 0); + + assertTrue(lib.connection_close(connHandle) != 0, "connection_close should return nonzero"); + } + + @Test + void hostShutdownReturnsTrueForValidHandle() { + assumeTrue(testLibExists(), "Native test library not found at " + SPIKE_LIB_PATH); + CallbackTestLib lib = loadTestLib(); + int serverHandle = lib.host_start(); + assertTrue(lib.host_shutdown(serverHandle) != 0, "host_shutdown should return nonzero"); + } +}