Skip to content

Commit c89a5e2

Browse files
Copilotedburns
andauthored
Add PlatformDetector and NativeRuntimeLoader with tests and resource filtering
Co-authored-by: edburns <75821+edburns@users.noreply.github.com>
1 parent bffa7ec commit c89a5e2

8 files changed

Lines changed: 1278 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: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
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_CLI_PATH} environment variable (if set, treated as the
28+
* resolved 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+
53+
private NativeRuntimeLoader() {
54+
}
55+
56+
/**
57+
* Resolves the filesystem path to the {@code runtime.node} native binary,
58+
* extracting and caching it from the classpath if necessary.
59+
*
60+
* @return the absolute path to an existing, non-empty {@code runtime.node} file
61+
* @throws NativeRuntimeLoaderException
62+
* if the binary cannot be resolved, extracted, or cached
63+
*/
64+
public static Path resolve() throws NativeRuntimeLoaderException {
65+
// 1. COPILOT_CLI_PATH override
66+
String cliPathEnv = System.getenv("COPILOT_CLI_PATH");
67+
if (cliPathEnv != null && !cliPathEnv.isBlank()) {
68+
return Paths.get(cliPathEnv);
69+
}
70+
71+
// 2. Extract from classpath resource
72+
String version = loadVersion();
73+
String classifier = PlatformDetector.detectClassifier();
74+
String resourcePath = "native/" + classifier + "/" + BINARY_NAME;
75+
76+
URL resourceUrl = NativeRuntimeLoader.class.getClassLoader().getResource(resourcePath);
77+
if (resourceUrl != null) {
78+
return extractToCache(resourceUrl, version, classifier);
79+
}
80+
81+
// 3. Alongside bundled CLI (fall-through when no classpath resource)
82+
String bundledCli = System.getenv("COPILOT_CLI_PATH");
83+
if (bundledCli != null && !bundledCli.isBlank()) {
84+
Path sibling = Paths.get(bundledCli).getParent();
85+
if (sibling != null) {
86+
Path candidate = sibling.resolve(BINARY_NAME);
87+
if (isValidCacheEntry(candidate)) {
88+
return candidate;
89+
}
90+
}
91+
}
92+
93+
throw new NativeRuntimeLoaderException("Could not locate native/" + classifier
94+
+ "/runtime.node on the classpath. " + "Ensure a platform-specific native JAR is on the classpath.");
95+
}
96+
97+
/**
98+
* Loads the artifact version from the {@code copilot-runtime.properties}
99+
* resource on the classpath.
100+
*
101+
* @return the non-blank version string
102+
* @throws NativeRuntimeLoaderException
103+
* if the resource is missing or the version value is blank
104+
*/
105+
static String loadVersion() throws NativeRuntimeLoaderException {
106+
InputStream in = NativeRuntimeLoader.class.getClassLoader().getResourceAsStream(PROPERTIES_RESOURCE);
107+
if (in == null) {
108+
throw new NativeRuntimeLoaderException("Missing classpath resource: " + PROPERTIES_RESOURCE
109+
+ ". Ensure the SDK JAR was built with Maven resource filtering enabled.");
110+
}
111+
Properties props = new Properties();
112+
try (in) {
113+
props.load(in);
114+
} catch (IOException e) {
115+
throw new NativeRuntimeLoaderException("Failed to read " + PROPERTIES_RESOURCE + ": " + e.getMessage(), e);
116+
}
117+
String version = props.getProperty("version");
118+
if (version == null || version.isBlank() || version.startsWith("${")) {
119+
throw new NativeRuntimeLoaderException(
120+
"Version property in " + PROPERTIES_RESOURCE + " is missing or was not filtered by Maven. "
121+
+ "Rebuild the project with Maven to apply resource filtering.");
122+
}
123+
return version.trim();
124+
}
125+
126+
private static Path extractToCache(URL resourceUrl, String version, String classifier)
127+
throws NativeRuntimeLoaderException {
128+
Path cacheDir = Paths.get(System.getProperty("user.home"), ".copilot", "runtime-cache", version, classifier);
129+
Path cached = cacheDir.resolve(BINARY_NAME);
130+
131+
// 1. Cache hit: regular, non-empty file
132+
if (isValidCacheEntry(cached)) {
133+
LOG.fine("Native binary cache hit: " + cached);
134+
return cached;
135+
}
136+
137+
// 2. Create cache directory
138+
try {
139+
Files.createDirectories(cacheDir);
140+
} catch (IOException e) {
141+
throw new NativeRuntimeLoaderException("Failed to create native binary cache directory: " + cacheDir, e);
142+
}
143+
144+
// 3. Create unique temp file in same directory (ATOMIC_MOVE requires same
145+
// filesystem)
146+
Path temp = cacheDir.resolve(BINARY_NAME + ".tmp-" + UUID.randomUUID());
147+
try {
148+
extractToTemp(resourceUrl, temp);
149+
atomicPublish(temp, cached);
150+
} finally {
151+
// 6. Delete caller's temp file in finally block (no-op if already moved or
152+
// missing)
153+
try {
154+
Files.deleteIfExists(temp);
155+
} catch (IOException ignored) {
156+
// best-effort cleanup
157+
}
158+
}
159+
160+
return cached;
161+
}
162+
163+
private static void extractToTemp(URL resourceUrl, Path temp) throws NativeRuntimeLoaderException {
164+
try (InputStream in = resourceUrl.openStream();
165+
FileChannel fc = FileChannel.open(temp, StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE)) {
166+
// Copy via InputStream → temp path (piping through a buffer)
167+
byte[] buf = new byte[65536];
168+
long total = 0;
169+
int n;
170+
while ((n = in.read(buf)) >= 0) {
171+
int written = 0;
172+
while (written < n) {
173+
written += fc.write(java.nio.ByteBuffer.wrap(buf, written, n - written));
174+
}
175+
total += n;
176+
}
177+
if (total == 0) {
178+
throw new NativeRuntimeLoaderException(
179+
"Classpath resource native/…/runtime.node is empty; the native JAR may be corrupt.");
180+
}
181+
// 4. Flush and force to disk before atomic rename
182+
fc.force(true);
183+
} catch (IOException e) {
184+
throw new NativeRuntimeLoaderException("Failed to write native binary to temp file: " + temp, e);
185+
}
186+
}
187+
188+
private static void atomicPublish(Path temp, Path cached) throws NativeRuntimeLoaderException {
189+
// 5. Atomic rename
190+
try {
191+
Files.move(temp, cached, StandardCopyOption.ATOMIC_MOVE);
192+
LOG.fine("Native binary extracted to cache: " + cached);
193+
} catch (AtomicMoveNotSupportedException e) {
194+
throw new NativeRuntimeLoaderException(
195+
"Filesystem does not support atomic moves; cannot safely publish native binary to " + cached
196+
+ ". Use a local filesystem for the home directory.",
197+
e);
198+
} catch (IOException e) {
199+
// Another process may have published first — accept if valid
200+
if (isValidCacheEntry(cached)) {
201+
LOG.fine("Native binary race: another process published first, accepting winner: " + cached);
202+
return;
203+
}
204+
throw new NativeRuntimeLoaderException("Failed to atomically publish native binary to " + cached, e);
205+
}
206+
}
207+
208+
/**
209+
* Returns {@code true} if {@code path} is a regular, non-empty file.
210+
*
211+
* @param path
212+
* the path to check
213+
* @return {@code true} if the cache entry is valid
214+
*/
215+
static boolean isValidCacheEntry(Path path) {
216+
try {
217+
return Files.isRegularFile(path) && Files.size(path) > 0;
218+
} catch (IOException e) {
219+
return false;
220+
}
221+
}
222+
}
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)