|
| 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 | +} |
0 commit comments