Skip to content

Commit b5c36e7

Browse files
Merge pull request #260 from vaiju1981/fix/loader-extraction-race
fix(loader): make native-lib extraction race-safe (atomic write + con…
2 parents d8076a8 + 7827a82 commit b5c36e7

3 files changed

Lines changed: 73 additions & 24 deletions

File tree

TODO.md

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -26,12 +26,19 @@ read-confirmed by the audit but warrant a quick re-check before the fix.
2626
doc) **DONE** — verified (452/452 C++ tests, affected Java tests + new regressions, Javadoc + Spotless +
2727
clang-format 22.1.5 clean).
2828

29-
**Deferred — `LlamaLoader` native-lib extraction temp-path race (open).** Fixing it (per-process temp
30-
dir / atomic move) is the one audit item left undone: it sits on *the* native-library load path, so the
31-
change has a high blast radius and needs careful Windows / `deleteOnExit` / `cleanup()` co-design plus
32-
cross-platform load testing (a locked-DLL replace on Windows, leftover unique-dir accumulation) that the
33-
fix-batch could not safely exercise. The race only bites concurrent JVMs sharing one `java.io.tmpdir`
34-
(e.g. some CI matrices). Pick it up as its own change with a Windows test pass.
29+
**`LlamaLoader` native-lib extraction temp-path race — DONE (atomic write + content-reuse).**
30+
`extractFile` now (1) reuses a byte-identical existing copy instead of rewriting it — so it never
31+
replaces a file another JVM has already loaded (which fails on Windows) — and (2) otherwise extracts
32+
to a per-attempt unique temp file and **atomically moves** it into place, so a concurrent loader can
33+
never observe a half-written library. `jllama` is statically linked (`BUILD_SHARED_LIBS OFF`), so the
34+
extracted file is self-contained — no multi-DLL co-location to coordinate. Verified by the
35+
`NativeLibraryLoadSmokeTest` (real extract+load on macOS) + a `resourceMatchesFile` unit test; the
36+
Windows locked-replace path is exercised by CI's Windows jobs.
37+
38+
*Optional follow-up (lower priority):* full per-process extraction **directory** isolation + a
39+
`cleanup()` that recursively removes dead-process dirs. Now that writes are atomic and content-checked,
40+
this is a tidiness improvement (stops the shared-tmpdir `cleanup()` from racing a live peer's flat
41+
file), not a correctness fix — and it still needs the Windows locked-file co-design noted before.
3542

3643
**Tier 1 — high impact, fix first**
3744

src/main/java/net/ladenthin/llama/loader/LlamaLoader.java

Lines changed: 48 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -210,33 +210,40 @@ public static boolean loadNativeLibrary(Path path) {
210210
Path extractedFilePath = Paths.get(targetDirectory, fileName);
211211

212212
try {
213-
// Extract a native library file into the target directory
214-
try (InputStream reader = LlamaLoader.class.getResourceAsStream(nativeLibraryFilePath)) {
215-
if (reader == null) {
216-
return null;
217-
}
218-
Files.copy(reader, extractedFilePath, StandardCopyOption.REPLACE_EXISTING);
219-
} finally {
220-
// Delete the extracted lib file on JVM exit.
213+
// Fast path: a byte-identical copy already exists — extracted by a previous run or by a
214+
// concurrent JVM sharing this tmpdir. Reuse it rather than rewriting in place: replacing a
215+
// file another process has already loaded fails on Windows (the lib is locked), and an
216+
// in-place rewrite risks a partial file a concurrent loader could observe.
217+
if (Files.exists(extractedFilePath) && resourceMatchesFile(nativeLibraryFilePath, extractedFilePath)) {
218+
permissionSetter.apply(extractedFilePath.toFile());
221219
extractedFilePath.toFile().deleteOnExit();
220+
return extractedFilePath;
222221
}
223222

224-
// Set executable (x) flag to enable Java to load the native library
225-
permissionSetter.apply(extractedFilePath.toFile());
226-
227-
// Check whether the contents are properly copied from the resource folder
228-
try (InputStream nativeIn = LlamaLoader.class.getResourceAsStream(nativeLibraryFilePath);
229-
InputStream extractedLibIn = Files.newInputStream(extractedFilePath)) {
230-
if (nativeIn == null) {
231-
System.err.println(String.format("Native library resource missing at %s", nativeLibraryFilePath));
232-
return null;
223+
// Otherwise extract into a per-attempt unique temp file, verify it, then atomically move it
224+
// into place so a concurrent loader never observes a half-written library.
225+
Path tempFile = Files.createTempFile(Paths.get(targetDirectory), fileName + ".", ".tmp");
226+
try {
227+
try (InputStream reader = LlamaLoader.class.getResourceAsStream(nativeLibraryFilePath)) {
228+
if (reader == null) {
229+
return null;
230+
}
231+
Files.copy(reader, tempFile, StandardCopyOption.REPLACE_EXISTING);
233232
}
234-
if (!contentsEquals(nativeIn, extractedLibIn)) {
233+
if (!resourceMatchesFile(nativeLibraryFilePath, tempFile)) {
235234
System.err.println(String.format("Failed to write a native library file at %s", extractedFilePath));
236235
return null;
237236
}
237+
moveIntoPlace(tempFile, extractedFilePath);
238+
} finally {
239+
// No-op once moveIntoPlace consumed it; cleans up if any step above bailed out.
240+
Files.deleteIfExists(tempFile);
238241
}
239242

243+
// Set executable (x) flag to enable Java to load the native library.
244+
permissionSetter.apply(extractedFilePath.toFile());
245+
extractedFilePath.toFile().deleteOnExit();
246+
240247
System.out.println("Extracted '" + fileName + "' to '" + extractedFilePath + "'");
241248
return extractedFilePath;
242249
} catch (IOException e) {
@@ -245,6 +252,29 @@ public static boolean loadNativeLibrary(Path path) {
245252
}
246253
}
247254

255+
/**
256+
* Atomically replace {@code target} with {@code source}, falling back to a plain move when the
257+
* filesystem does not support atomic moves.
258+
*/
259+
private static void moveIntoPlace(Path source, Path target) throws IOException {
260+
try {
261+
Files.move(source, target, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE);
262+
} catch (IOException atomicUnsupported) {
263+
Files.move(source, target, StandardCopyOption.REPLACE_EXISTING);
264+
}
265+
}
266+
267+
/** Whether the classpath resource at {@code resourcePath} is byte-identical to {@code file}. */
268+
static boolean resourceMatchesFile(String resourcePath, Path file) throws IOException {
269+
try (InputStream resource = LlamaLoader.class.getResourceAsStream(resourcePath);
270+
InputStream onDisk = Files.newInputStream(file)) {
271+
if (resource == null) {
272+
return false;
273+
}
274+
return contentsEquals(resource, onDisk);
275+
}
276+
}
277+
248278
/**
249279
* Extracts and loads the specified library file to the target folder
250280
*

src/test/java/net/ladenthin/llama/loader/LlamaLoaderTest.java

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,18 @@ public void testContentsEqualsIdenticalContent() throws IOException {
9999
assertTrue(LlamaLoader.contentsEquals(new ByteArrayInputStream(data), new ByteArrayInputStream(data)));
100100
}
101101

102+
@Test
103+
public void resourceMatchesFileFalseWhenResourceAbsent() throws IOException {
104+
java.nio.file.Path tmp = java.nio.file.Files.createTempFile("llama-loader-test", ".bin");
105+
try {
106+
java.nio.file.Files.write(tmp, new byte[] {1, 2, 3});
107+
// A missing classpath resource must compare as "not matching", never throw.
108+
assertFalse(LlamaLoader.resourceMatchesFile("/net/ladenthin/llama/does-not-exist.bin", tmp));
109+
} finally {
110+
java.nio.file.Files.deleteIfExists(tmp);
111+
}
112+
}
113+
102114
@Test
103115
public void testContentsEqualsBothEmpty() throws IOException {
104116
assertTrue(LlamaLoader.contentsEquals(

0 commit comments

Comments
 (0)