Skip to content

Commit a37469a

Browse files
CopilotedburnsCopilot
committed
[Java] Add PlatformDetector and NativeRuntimeLoader for native binary extraction and caching (tasks 4.2 + 4.3) (#2157)
* Initial plan * Add PlatformDetector and NativeRuntimeLoader with tests and resource filtering Co-authored-by: edburns <75821+edburns@users.noreply.github.com> * Address Copilot review findings for FFI loader tests 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 ab2ac16 commit a37469a

8 files changed

Lines changed: 1128 additions & 0 deletions

File tree

java/pom.xml

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,28 @@
134134
</dependencies>
135135

136136
<build>
137+
<!--
138+
Enable Maven resource filtering for copilot-runtime.properties so that
139+
${project.version} is replaced with the actual artifact version at
140+
build time. NativeRuntimeLoader reads this resource to determine the
141+
version-keyed cache directory for native binary extraction.
142+
-->
143+
<resources>
144+
<resource>
145+
<directory>src/main/resources</directory>
146+
<filtering>true</filtering>
147+
<includes>
148+
<include>copilot-runtime.properties</include>
149+
</includes>
150+
</resource>
151+
<resource>
152+
<directory>src/main/resources</directory>
153+
<filtering>false</filtering>
154+
<excludes>
155+
<exclude>copilot-runtime.properties</exclude>
156+
</excludes>
157+
</resource>
158+
</resources>
137159
<pluginManagement>
138160
<plugins>
139161
<plugin>
Lines changed: 232 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,232 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* Copyright (c) Microsoft Corporation. All rights reserved.
3+
*--------------------------------------------------------------------------------------------*/
4+
5+
package com.github.copilot.ffi;
6+
7+
import java.io.IOException;
8+
import java.io.InputStream;
9+
import java.net.URL;
10+
import java.nio.channels.FileChannel;
11+
import java.nio.file.AtomicMoveNotSupportedException;
12+
import java.nio.file.Files;
13+
import java.nio.file.Path;
14+
import java.nio.file.Paths;
15+
import java.nio.file.StandardCopyOption;
16+
import java.nio.file.StandardOpenOption;
17+
import java.util.Properties;
18+
import java.util.UUID;
19+
import java.util.logging.Logger;
20+
21+
/**
22+
* Locates, extracts, and caches the {@code runtime.node} native binary.
23+
*
24+
* <p>
25+
* Resolution order:
26+
* <ol>
27+
* <li>The {@code COPILOT_RUNTIME_PATH} environment variable (if set, treated as
28+
* the resolved {@code runtime.node} path and returned directly).</li>
29+
* <li>Classpath resource {@code native/<classifier>/runtime.node} extracted to
30+
* {@code ~/.copilot/runtime-cache/<version>/<classifier>/runtime.node}.</li>
31+
* <li>A {@code runtime.node} file alongside the bundled CLI binary.</li>
32+
* </ol>
33+
*
34+
* <p>
35+
* The version is read from the {@code copilot-runtime.properties} resource that
36+
* is written by Maven resource filtering at build time. A missing or blank
37+
* version is a configuration error and causes {@link #resolve()} to throw.
38+
*
39+
* <p>
40+
* Extraction is atomic: the binary is written to a unique sibling temp file and
41+
* renamed into place with {@link StandardCopyOption#ATOMIC_MOVE}. If another
42+
* process wins the race, the winner's file is accepted after a
43+
* regular/non-empty sanity check. No file locking is used. The execute
44+
* permission bit is NOT set on the extracted file; JNA's {@code dlopen} does
45+
* not require it.
46+
*/
47+
public final class NativeRuntimeLoader {
48+
49+
private static final Logger LOG = Logger.getLogger(NativeRuntimeLoader.class.getName());
50+
private static final String PROPERTIES_RESOURCE = "copilot-runtime.properties";
51+
private static final String BINARY_NAME = "runtime.node";
52+
private static final String RUNTIME_PATH_ENV = "COPILOT_RUNTIME_PATH";
53+
private static final String CLI_PATH_ENV = "COPILOT_CLI_PATH";
54+
55+
private NativeRuntimeLoader() {
56+
}
57+
58+
/**
59+
* Resolves the filesystem path to the {@code runtime.node} native binary,
60+
* extracting and caching it from the classpath if necessary.
61+
*
62+
* @return the absolute path to an existing, non-empty {@code runtime.node} file
63+
* @throws NativeRuntimeLoaderException
64+
* if the binary cannot be resolved, extracted, or cached
65+
*/
66+
public static Path resolve() throws NativeRuntimeLoaderException {
67+
return resolve(System.getenv(RUNTIME_PATH_ENV), System.getenv(CLI_PATH_ENV),
68+
NativeRuntimeLoader.class.getClassLoader(), Paths.get(System.getProperty("user.home")),
69+
PlatformDetector.detectClassifier());
70+
}
71+
72+
static Path resolve(String runtimePathOverride, String bundledCliPath, ClassLoader classLoader, Path userHome,
73+
String classifier) throws NativeRuntimeLoaderException {
74+
// 1. Explicit runtime.node override
75+
if (runtimePathOverride != null && !runtimePathOverride.isBlank()) {
76+
return Paths.get(runtimePathOverride);
77+
}
78+
79+
// 2. Extract from classpath resource
80+
String resourcePath = "native/" + classifier + "/" + BINARY_NAME;
81+
82+
URL resourceUrl = classLoader.getResource(resourcePath);
83+
if (resourceUrl != null) {
84+
String version = loadVersion(classLoader);
85+
return extractToCache(resourceUrl, version, classifier, userHome);
86+
}
87+
88+
// 3. Alongside bundled CLI (fall-through when no classpath resource)
89+
if (bundledCliPath != null && !bundledCliPath.isBlank()) {
90+
Path sibling = Paths.get(bundledCliPath).getParent();
91+
if (sibling != null) {
92+
Path candidate = sibling.resolve(BINARY_NAME);
93+
if (isValidCacheEntry(candidate)) {
94+
return candidate;
95+
}
96+
}
97+
}
98+
99+
throw new NativeRuntimeLoaderException("Could not locate native/" + classifier
100+
+ "/runtime.node on the classpath. " + "Ensure a platform-specific native JAR is on the classpath.");
101+
}
102+
103+
/**
104+
* Loads the artifact version from the {@code copilot-runtime.properties}
105+
* resource on the classpath.
106+
*
107+
* @return the non-blank version string
108+
* @throws NativeRuntimeLoaderException
109+
* if the resource is missing or the version value is blank
110+
*/
111+
static String loadVersion() throws NativeRuntimeLoaderException {
112+
return loadVersion(NativeRuntimeLoader.class.getClassLoader());
113+
}
114+
115+
static String loadVersion(ClassLoader classLoader) throws NativeRuntimeLoaderException {
116+
InputStream in = classLoader.getResourceAsStream(PROPERTIES_RESOURCE);
117+
if (in == null) {
118+
throw new NativeRuntimeLoaderException("Missing classpath resource: " + PROPERTIES_RESOURCE
119+
+ ". Ensure the SDK JAR was built with Maven resource filtering enabled.");
120+
}
121+
Properties props = new Properties();
122+
try (in) {
123+
props.load(in);
124+
} catch (IOException e) {
125+
throw new NativeRuntimeLoaderException("Failed to read " + PROPERTIES_RESOURCE + ": " + e.getMessage(), e);
126+
}
127+
String version = props.getProperty("version");
128+
if (version == null || version.isBlank() || version.startsWith("${")) {
129+
throw new NativeRuntimeLoaderException(
130+
"Version property in " + PROPERTIES_RESOURCE + " is missing or was not filtered by Maven. "
131+
+ "Rebuild the project with Maven to apply resource filtering.");
132+
}
133+
return version.trim();
134+
}
135+
136+
static Path extractToCache(URL resourceUrl, String version, String classifier, Path userHome)
137+
throws NativeRuntimeLoaderException {
138+
Path cacheDir = userHome.resolve(Paths.get(".copilot", "runtime-cache", version, classifier));
139+
Path cached = cacheDir.resolve(BINARY_NAME);
140+
141+
// 1. Cache hit: regular, non-empty file
142+
if (isValidCacheEntry(cached)) {
143+
LOG.fine("Native binary cache hit: " + cached);
144+
return cached;
145+
}
146+
147+
// 2. Create cache directory
148+
try {
149+
Files.createDirectories(cacheDir);
150+
} catch (IOException e) {
151+
throw new NativeRuntimeLoaderException("Failed to create native binary cache directory: " + cacheDir, e);
152+
}
153+
154+
// 3. Create unique temp file in same directory (ATOMIC_MOVE requires same
155+
// filesystem)
156+
Path temp = cacheDir.resolve(BINARY_NAME + ".tmp-" + UUID.randomUUID());
157+
try {
158+
extractToTemp(resourceUrl, temp);
159+
atomicPublish(temp, cached);
160+
} finally {
161+
// 6. Delete caller's temp file in finally block (no-op if already moved or
162+
// missing)
163+
try {
164+
Files.deleteIfExists(temp);
165+
} catch (IOException ignored) {
166+
// best-effort cleanup
167+
}
168+
}
169+
170+
return cached;
171+
}
172+
173+
private static void extractToTemp(URL resourceUrl, Path temp) throws NativeRuntimeLoaderException {
174+
try (InputStream in = resourceUrl.openStream();
175+
FileChannel fc = FileChannel.open(temp, StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE)) {
176+
// Copy via InputStream → temp path (piping through a buffer)
177+
byte[] buf = new byte[65536];
178+
long total = 0;
179+
int n;
180+
while ((n = in.read(buf)) >= 0) {
181+
int written = 0;
182+
while (written < n) {
183+
written += fc.write(java.nio.ByteBuffer.wrap(buf, written, n - written));
184+
}
185+
total += n;
186+
}
187+
if (total == 0) {
188+
throw new NativeRuntimeLoaderException(
189+
"Classpath resource native/…/runtime.node is empty; the native JAR may be corrupt.");
190+
}
191+
// 4. Flush and force to disk before atomic rename
192+
fc.force(true);
193+
} catch (IOException e) {
194+
throw new NativeRuntimeLoaderException("Failed to write native binary to temp file: " + temp, e);
195+
}
196+
}
197+
198+
private static void atomicPublish(Path temp, Path cached) throws NativeRuntimeLoaderException {
199+
// 5. Atomic rename
200+
try {
201+
Files.move(temp, cached, StandardCopyOption.ATOMIC_MOVE);
202+
LOG.fine("Native binary extracted to cache: " + cached);
203+
} catch (AtomicMoveNotSupportedException e) {
204+
throw new NativeRuntimeLoaderException(
205+
"Filesystem does not support atomic moves; cannot safely publish native binary to " + cached
206+
+ ". Use a local filesystem for the home directory.",
207+
e);
208+
} catch (IOException e) {
209+
// Another process may have published first — accept if valid
210+
if (isValidCacheEntry(cached)) {
211+
LOG.fine("Native binary race: another process published first, accepting winner: " + cached);
212+
return;
213+
}
214+
throw new NativeRuntimeLoaderException("Failed to atomically publish native binary to " + cached, e);
215+
}
216+
}
217+
218+
/**
219+
* Returns {@code true} if {@code path} is a regular, non-empty file.
220+
*
221+
* @param path
222+
* the path to check
223+
* @return {@code true} if the cache entry is valid
224+
*/
225+
static boolean isValidCacheEntry(Path path) {
226+
try {
227+
return Files.isRegularFile(path) && Files.size(path) > 0;
228+
} catch (IOException e) {
229+
return false;
230+
}
231+
}
232+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* Copyright (c) Microsoft Corporation. All rights reserved.
3+
*--------------------------------------------------------------------------------------------*/
4+
5+
package com.github.copilot.ffi;
6+
7+
/**
8+
* Thrown when the {@code runtime.node} native binary cannot be resolved,
9+
* extracted, or cached by {@link NativeRuntimeLoader}.
10+
*/
11+
public final class NativeRuntimeLoaderException extends Exception {
12+
13+
private static final long serialVersionUID = 1L;
14+
15+
/**
16+
* Constructs a new exception with the given detail message.
17+
*
18+
* @param message
19+
* the detail message
20+
*/
21+
public NativeRuntimeLoaderException(String message) {
22+
super(message);
23+
}
24+
25+
/**
26+
* Constructs a new exception with the given detail message and cause.
27+
*
28+
* @param message
29+
* the detail message
30+
* @param cause
31+
* the cause
32+
*/
33+
public NativeRuntimeLoaderException(String message, Throwable cause) {
34+
super(message, cause);
35+
}
36+
}

0 commit comments

Comments
 (0)