Skip to content

Commit 9e06d26

Browse files
CopilotedburnsCopilot
committed
[Java] Embed Rust CLI runtime 4.3: NativeRuntimeLoader — native binary extraction and caching (#2175)
* Initial plan * feat(java): implement NativeRuntimeLoader for runtime.node extraction and caching (task 4.3) Co-authored-by: edburns <75821+edburns@users.noreply.github.com> * fix(java): fix NativeRuntimeLoader to implement 3-source resolution order and add AtomicPublisher test seam Co-authored-by: edburns <75821+edburns@users.noreply.github.com> * fix(java): harden native runtime resolution Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(java): reconcile native loader review fixes 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 f15ff68 commit 9e06d26

4 files changed

Lines changed: 820 additions & 0 deletions

File tree

java/sdk/pom.xml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,12 @@
106106
</dependencies>
107107

108108
<build>
109+
<resources>
110+
<resource>
111+
<directory>src/main/resources</directory>
112+
<filtering>true</filtering>
113+
</resource>
114+
</resources>
109115
<pluginManagement>
110116
<plugins>
111117
<plugin>
Lines changed: 360 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,360 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* Copyright (c) Microsoft Corporation. All rights reserved.
3+
*--------------------------------------------------------------------------------------------*/
4+
5+
package com.github.copilot.ffi;
6+
7+
import java.io.FileNotFoundException;
8+
import java.io.IOException;
9+
import java.io.InputStream;
10+
import java.net.URL;
11+
import java.nio.channels.FileChannel;
12+
import java.nio.file.AtomicMoveNotSupportedException;
13+
import java.nio.file.FileAlreadyExistsException;
14+
import java.nio.file.Files;
15+
import java.nio.file.Path;
16+
import java.nio.file.StandardCopyOption;
17+
import java.nio.file.StandardOpenOption;
18+
import java.util.Properties;
19+
20+
/**
21+
* Locates the {@code runtime.node} native binary, extracts it to a versioned
22+
* cache directory, and returns the filesystem path for JNA to load.
23+
*
24+
* <p>
25+
* Resolution order:
26+
* <ol>
27+
* <li><strong>{@code COPILOT_CLI_PATH}</strong> — checks for
28+
* {@code runtime.node} alongside the configured CLI before any classpath or
29+
* platform work.</li>
30+
* <li><strong>Classpath resource</strong>
31+
* {@code native/<classifier>/runtime.node} — extracted atomically to
32+
* {@code ~/.copilot/runtime-cache/<version>/<classifier>/runtime.node}.</li>
33+
* <li>{@code runtime.node} alongside the bundled {@code copilot}
34+
* executable.</li>
35+
* </ol>
36+
*/
37+
public final class NativeRuntimeLoader {
38+
39+
static final String RUNTIME_FILENAME = "runtime.node";
40+
static final String COPILOT_CLI_PATH_ENV = "COPILOT_CLI_PATH";
41+
static final String VERSION_RESOURCE = "copilot-runtime.properties";
42+
43+
/**
44+
* Abstraction for the atomic publish step, enabling deterministic failure
45+
* injection in tests while preserving {@link StandardCopyOption#ATOMIC_MOVE} in
46+
* production.
47+
*/
48+
@FunctionalInterface
49+
interface AtomicPublisher {
50+
/**
51+
* Atomically publishes {@code temp} to {@code cached}.
52+
*
53+
* @param temp
54+
* fully-written temporary file in the same directory as
55+
* {@code cached}
56+
* @param cached
57+
* intended final location
58+
* @throws IOException
59+
* if the move fails
60+
*/
61+
void publish(Path temp, Path cached) throws IOException;
62+
}
63+
64+
/**
65+
* Production publisher: {@link Files#move} with
66+
* {@link StandardCopyOption#ATOMIC_MOVE}.
67+
*/
68+
static final AtomicPublisher DEFAULT_PUBLISHER = (temp, cached) -> {
69+
try {
70+
Files.move(temp, cached, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
71+
} catch (AtomicMoveNotSupportedException ex) {
72+
throw new IllegalStateException("Filesystem does not support atomic moves; cannot safely publish "
73+
+ RUNTIME_FILENAME + " to " + cached, ex);
74+
} catch (FileAlreadyExistsException ex) {
75+
// Another process won the race — accept the winner if it is a valid file.
76+
try {
77+
if (isValidCachedFile(cached)) {
78+
return;
79+
}
80+
} catch (IOException ignored) {
81+
// fall through to the error below
82+
}
83+
throw new IllegalStateException(
84+
"Concurrent extraction race: target already exists but is not a valid file: " + cached, ex);
85+
}
86+
};
87+
88+
private NativeRuntimeLoader() {
89+
}
90+
91+
/**
92+
* Resolves the filesystem path to the {@code runtime.node} binary.
93+
*
94+
* <p>
95+
* Follows the three-step resolution order documented on this class. The
96+
* returned path is guaranteed to refer to a regular, non-empty file at the time
97+
* of return.
98+
*
99+
* @return absolute path to the {@code runtime.node} binary
100+
* @throws IOException
101+
* if the binary cannot be located or extracted
102+
* @throws IllegalStateException
103+
* if required resources are missing or extraction fails
104+
*/
105+
public static Path resolve() throws IOException {
106+
String cliPathEnv = System.getenv(COPILOT_CLI_PATH_ENV);
107+
Path cliOverride = resolveFromCliPath(cliPathEnv);
108+
if (cliOverride != null) {
109+
return cliOverride;
110+
}
111+
112+
ClassLoader loader = NativeRuntimeLoader.class.getClassLoader();
113+
String classifier = PlatformDetector.detectClassifier();
114+
String version = readVersion(loader);
115+
Path cacheBase = defaultCacheBase();
116+
return resolve(null, findCliOnPath(), cacheBase, loader, classifier, version);
117+
}
118+
119+
/**
120+
* Reads the SDK version from the filtered {@code copilot-runtime.properties}
121+
* resource.
122+
*
123+
* @return the version string
124+
* @throws IOException
125+
* if the resource cannot be read
126+
* @throws IllegalStateException
127+
* if the resource is missing or the version property is blank
128+
*/
129+
static String readVersion(ClassLoader loader) throws IOException {
130+
URL resource = loader.getResource(VERSION_RESOURCE);
131+
if (resource == null) {
132+
throw new IllegalStateException("Missing version resource: " + VERSION_RESOURCE
133+
+ " — ensure Maven resource filtering has run (mvn process-resources)");
134+
}
135+
Properties props = new Properties();
136+
try (InputStream in = resource.openStream()) {
137+
props.load(in);
138+
}
139+
String version = props.getProperty("version");
140+
if (version == null || version.isBlank()) {
141+
throw new IllegalStateException("Blank or missing 'version' property in " + VERSION_RESOURCE
142+
+ " — check Maven resource filtering configuration");
143+
}
144+
return version;
145+
}
146+
147+
/**
148+
* Resolves the runtime binary path using the given parameters. Package-private
149+
* to allow injection of test doubles in unit tests.
150+
*/
151+
static Path resolve(String cliPathEnv, Path cacheBase, ClassLoader loader, String classifier, String version)
152+
throws IOException {
153+
return resolve(cliPathEnv, cacheBase, loader, classifier, version, null, DEFAULT_PUBLISHER);
154+
}
155+
156+
static Path resolve(String cliPathEnv, String bundledCliPath, Path cacheBase, ClassLoader loader, String classifier,
157+
String version) throws IOException {
158+
Path bundledCliDir = bundledCliPath == null ? null : Path.of(bundledCliPath).toAbsolutePath().getParent();
159+
return resolve(cliPathEnv, cacheBase, loader, classifier, version, bundledCliDir, DEFAULT_PUBLISHER);
160+
}
161+
162+
static Path resolve(String cliPathEnv, Path cacheBase, ClassLoader loader, String classifier, String version,
163+
Path bundledCliDir) throws IOException {
164+
return resolve(cliPathEnv, cacheBase, loader, classifier, version, bundledCliDir, DEFAULT_PUBLISHER);
165+
}
166+
167+
static Path resolve(String cliPathEnv, Path cacheBase, ClassLoader loader, String classifier, String version,
168+
Path bundledCliDir, AtomicPublisher publisher) throws IOException {
169+
Path cliOverride = resolveFromCliPath(cliPathEnv);
170+
if (cliOverride != null) {
171+
return cliOverride;
172+
}
173+
174+
return resolveFromClasspathOrBundledCli(cacheBase, loader, classifier, version, bundledCliDir, publisher);
175+
}
176+
177+
/**
178+
* Checks for {@code runtime.node} alongside the configured CLI.
179+
*/
180+
static Path resolveFromCliPath(String cliPathStr) throws IOException {
181+
if (cliPathStr == null || cliPathStr.isBlank()) {
182+
return null;
183+
}
184+
Path cliPath = Path.of(cliPathStr).toAbsolutePath().normalize();
185+
Path parent = cliPath.getParent();
186+
Path candidate = parent.resolve(RUNTIME_FILENAME);
187+
if (Files.isRegularFile(candidate) && Files.size(candidate) > 0) {
188+
return candidate;
189+
}
190+
return null;
191+
}
192+
193+
/**
194+
* Extracts the classpath resource {@code native/<classifier>/runtime.node} to
195+
* the versioned cache directory, using an atomic publish sequence to prevent
196+
* readers from observing a partially-written file. Uses
197+
* {@link #DEFAULT_PUBLISHER}.
198+
*
199+
* @param cacheBase
200+
* root cache directory (e.g. {@code ~/.copilot/runtime-cache})
201+
* @param loader
202+
* class loader used to open the classpath resource
203+
* @param classifier
204+
* platform classifier (e.g. {@code linux-x64})
205+
* @param version
206+
* SDK version used as the cache key
207+
* @return path to the extracted {@code runtime.node} binary
208+
* @throws IOException
209+
* if I/O or the atomic rename fails
210+
* @throws IllegalStateException
211+
* if the classpath resource is missing or empty, or if the
212+
* filesystem does not support atomic moves
213+
*/
214+
static Path extractToCache(Path cacheBase, ClassLoader loader, String classifier, String version)
215+
throws IOException {
216+
return extractToCache(cacheBase, loader, classifier, version, DEFAULT_PUBLISHER);
217+
}
218+
219+
/**
220+
* Extracts the classpath resource to the versioned cache directory with an
221+
* injectable publisher. Package-private for unit tests.
222+
*
223+
* @param cacheBase
224+
* root cache directory
225+
* @param loader
226+
* class loader used to open the classpath resource
227+
* @param classifier
228+
* platform classifier
229+
* @param version
230+
* SDK version used as the cache key
231+
* @param publisher
232+
* atomic publish implementation
233+
* @return path to the extracted {@code runtime.node} binary
234+
* @throws IOException
235+
* if I/O or the atomic rename fails
236+
* @throws IllegalStateException
237+
* if the classpath resource is missing or empty
238+
*/
239+
static Path extractToCache(Path cacheBase, ClassLoader loader, String classifier, String version,
240+
AtomicPublisher publisher) throws IOException {
241+
String resourcePath = "native/" + classifier + "/" + RUNTIME_FILENAME;
242+
Path cacheDir = cacheBase.resolve(version).resolve(classifier);
243+
Path cached = cacheDir.resolve(RUNTIME_FILENAME);
244+
245+
// Step 1 — fast path: return an existing valid cache entry.
246+
if (isValidCachedFile(cached)) {
247+
return cached;
248+
}
249+
250+
// Step 2 — locate the classpath resource before creating any files.
251+
URL resource = loader.getResource(resourcePath);
252+
if (resource == null) {
253+
throw new FileNotFoundException("Native runtime not found on classpath: " + resourcePath
254+
+ " — add the matching classifier JAR to the classpath");
255+
}
256+
257+
// Step 3 — ensure the cache directory exists.
258+
Files.createDirectories(cacheDir);
259+
260+
// Step 4 — write to a unique sibling temp file, then publish atomically.
261+
Path temp = Files.createTempFile(cacheDir, "runtime-tmp-", ".node");
262+
try {
263+
copyResourceToTemp(resource, resourcePath, temp);
264+
publisher.publish(temp, cached);
265+
} finally {
266+
tryDelete(temp);
267+
}
268+
269+
return cached;
270+
}
271+
272+
/**
273+
* Tries source 2 (classpath extraction) first and falls back to source 3
274+
* (bundled-CLI sibling) only when the classpath resource is absent.
275+
*/
276+
private static Path resolveFromClasspathOrBundledCli(Path cacheBase, ClassLoader loader, String classifier,
277+
String version, Path bundledCliDir, AtomicPublisher publisher) throws IOException {
278+
// Source 2: classpath resource.
279+
try {
280+
return extractToCache(cacheBase, loader, classifier, version, publisher);
281+
} catch (FileNotFoundException ex) {
282+
// Source 3: runtime.node alongside the bundled CLI binary.
283+
if (bundledCliDir != null) {
284+
Path candidate = bundledCliDir.resolve(RUNTIME_FILENAME);
285+
try {
286+
if (isValidCachedFile(candidate)) {
287+
return candidate;
288+
}
289+
} catch (IOException ignored) {
290+
// fall through and rethrow the original classpath error
291+
}
292+
}
293+
throw ex;
294+
}
295+
}
296+
297+
private static boolean isValidCachedFile(Path path) throws IOException {
298+
if (!Files.isRegularFile(path)) {
299+
return false;
300+
}
301+
return Files.size(path) > 0;
302+
}
303+
304+
private static void copyResourceToTemp(URL resource, String resourcePath, Path temp) throws IOException {
305+
try (InputStream in = resource.openStream()) {
306+
long bytesWritten = Files.copy(in, temp, StandardCopyOption.REPLACE_EXISTING);
307+
if (bytesWritten == 0) {
308+
throw new IllegalStateException("Classpath resource is empty: " + resourcePath);
309+
}
310+
}
311+
// Flush OS buffers to durable storage before the atomic rename.
312+
try (FileChannel channel = FileChannel.open(temp, StandardOpenOption.WRITE)) {
313+
channel.force(true);
314+
}
315+
}
316+
317+
private static String findCliOnPath() {
318+
String pathValue = System.getenv("PATH");
319+
if (pathValue == null || pathValue.isBlank()) {
320+
return null;
321+
}
322+
323+
String[] executableNames = isWindows()
324+
? new String[]{"copilot.exe", "copilot.cmd", "copilot.bat", "copilot"}
325+
: new String[]{"copilot"};
326+
for (String directory : pathValue.split(java.io.File.pathSeparator)) {
327+
if (directory.isBlank()) {
328+
continue;
329+
}
330+
for (String executableName : executableNames) {
331+
Path candidate = Path.of(directory, executableName);
332+
if (Files.isRegularFile(candidate)) {
333+
try {
334+
return candidate.toRealPath().toString();
335+
} catch (IOException ignored) {
336+
return candidate.toAbsolutePath().normalize().toString();
337+
}
338+
}
339+
}
340+
}
341+
return null;
342+
}
343+
344+
private static boolean isWindows() {
345+
return System.getProperty("os.name", "").toLowerCase(java.util.Locale.ROOT).contains("win");
346+
}
347+
348+
private static void tryDelete(Path path) {
349+
try {
350+
Files.deleteIfExists(path);
351+
} catch (IOException ignored) {
352+
// Best-effort cleanup; an orphaned temp file in the cache directory is benign.
353+
}
354+
}
355+
356+
private static Path defaultCacheBase() {
357+
return Path.of(System.getProperty("user.home"), ".copilot", "runtime-cache");
358+
}
359+
360+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# This file is processed by Maven resource filtering.
2+
# The ${project.version} placeholder is replaced at build time.
3+
version=${project.version}

0 commit comments

Comments
 (0)