Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .claude/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,9 @@ version triggers a warning against `BuildInfo.ENGINE_VERSION`.

`libhegel` resolves from `$HEGEL_LIBHEGEL_PATH` (explicit override), else the OS's standard
shared-library search path (`LD_LIBRARY_PATH` on Linux, `DYLD_LIBRARY_PATH` on macOS), else the
native bundled in the jar for the host OS/arch (unpacked to a per-user cache). The bundled libraries are fetched at build time by
native bundled in the jar for the host OS/arch (unpacked to a per-user cache; the cache is
best-effort — if it cannot be read or written, e.g. under a sandbox that denies writes to the user
cache dir, the native is extracted to a fresh directory under the system temp dir instead). The bundled libraries are fetched at build time by
`scripts/fetch_natives.py` (wired into Maven's `generate-resources` phase), which discovers whatever
shared objects the pinned `<libhegel.version>` release publishes — no runtime download. The fetch is **strict** —
because the bundled native is the only way end users get the engine (no runtime-download
Expand Down
3 changes: 3 additions & 0 deletions RELEASE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
RELEASE_TYPE: patch

Fix error when cache directory for libhegel could not be written to, for example inside of a sandbox.
84 changes: 68 additions & 16 deletions src/main/java/dev/hegel/LibraryLoader.java
Original file line number Diff line number Diff line change
Expand Up @@ -27,23 +27,44 @@
*
* <p>The bundled libraries are placed on the classpath at build time (see {@code
* scripts/fetch_natives.py}), so the shipped jar is self-contained and nothing is downloaded at
* runtime. Configuration (environment, cache dir, OS/arch, and the resource opener) is injected so
* the resolver is fully unit-testable, including the unpack path.
* runtime. The per-user cache is purely a performance optimization: when it cannot be read or
* written (e.g. a sandbox that denies access to the user cache dir), the library is extracted to a
* fresh directory under the system temp dir instead. Configuration (environment, cache dir,
* OS/arch, the resource opener, and the temp-dir supplier) is injected so the resolver is fully
* unit-testable, including the unpack path.
*/
final class LibraryLoader {
/** Creates a fresh private directory for the temp-dir fallback; injected for testability. */
@FunctionalInterface
interface TempDirSupplier {
Path create() throws IOException;
}

private final Map<String, String> env;
private final Path cacheDir;
private final String os;
private final String arch;
private final Function<String, InputStream> resources;
private final TempDirSupplier tempDirs;

LibraryLoader(
Map<String, String> env, Path cacheDir, String os, String arch, Function<String, InputStream> resources) {
this(env, cacheDir, os, arch, resources, () -> Files.createTempDirectory("hegel-java-libhegel"));
}

LibraryLoader(
Map<String, String> env,
Path cacheDir,
String os,
String arch,
Function<String, InputStream> resources,
TempDirSupplier tempDirs) {
this.env = env;
this.cacheDir = cacheDir;
this.os = os;
this.arch = arch;
this.resources = resources;
this.tempDirs = tempDirs;
}

/**
Expand Down Expand Up @@ -170,9 +191,10 @@ Path searchLibraryPath() {
}

/**
* Unpack the bundled native for this OS/arch to the cache and return its path, or {@code null} if
* no native is bundled for this platform. The cache entry is keyed by the library's content hash,
* so it is reused across runs and never collides between engine versions.
* Unpack the bundled native for this OS/arch and return its path, or {@code null} if no native
* is bundled for this platform. The per-user cache is tried first; on any cache failure the
* library is extracted to a fresh directory under the system temp dir instead. Unpacking fails
* only when both paths fail, with an error reporting both causes.
*/
Path unpackBundled() {
InputStream in = resources.apply(resourcePath());
Expand All @@ -185,23 +207,53 @@ Path unpackBundled() {
} catch (IOException e) {
throw new HegelException("Failed to read bundled libhegel resource " + resourcePath(), e);
}
IOException cacheFailure;
try {
return cachedLibrary(bytes);
} catch (IOException e) {
cacheFailure = e;
}
try {
return tempLibrary(bytes);
} catch (IOException e) {
e.addSuppressed(cacheFailure);
throw new HegelException(
"Failed to unpack bundled libhegel to a temp dir (cache also unusable: " + cacheFailure + ")", e);
}
}

/**
* The per-user cached copy of the library, written on first use. The cache entry is keyed by
* the library's content hash, so it is reused across runs and never collides between engine
* versions.
*/
private Path cachedLibrary(byte[] bytes) throws IOException {
Path dir = cacheDir.resolve(sha256Hex(bytes));
Path target = dir.resolve(libFileName());
if (Files.isRegularFile(target) && target.toFile().length() == bytes.length) {
return target;
}
Files.createDirectories(dir);
return installLibrary(dir, bytes);
}

/**
* Extract the library to a fresh private directory under the system temp dir.
*/
private Path tempLibrary(byte[] bytes) throws IOException {
return installLibrary(tempDirs.create(), bytes);
}

/** Write the library into {@code dir} atomically (temp file + rename), marked executable. */
private Path installLibrary(Path dir, byte[] bytes) throws IOException {
Path target = dir.resolve(libFileName());
Path tmp = Files.createTempFile(dir, "libhegel", ".part");
try {
Files.createDirectories(dir);
Path tmp = Files.createTempFile(dir, "libhegel", ".part");
try {
Files.write(tmp, bytes);
tmp.toFile().setExecutable(true, false);
Files.move(tmp, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
} finally {
Files.deleteIfExists(tmp);
}
} catch (IOException e) {
throw new HegelException("Failed to unpack bundled libhegel to " + target, e);
Files.write(tmp, bytes);
tmp.toFile().setExecutable(true, false);
Files.move(tmp, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
} finally {
Files.deleteIfExists(tmp);
}
return target;
}
Expand Down
25 changes: 22 additions & 3 deletions src/test/java/dev/hegel/LibraryLoaderTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
Expand Down Expand Up @@ -240,12 +241,30 @@ private static String warnOutput(FakeLibhegel lib, String expected) {
}

@Test
void unpackIoErrorFails(@TempDir Path dir) throws IOException {
byte[] payload = "x".getBytes(StandardCharsets.UTF_8);
void unusableCacheFallsBackToTempDir(@TempDir Path dir) throws IOException {
byte[] payload = "ELF-ish-bytes".getBytes(StandardCharsets.UTF_8);
Path cacheAsFile = dir.resolve("cache-is-a-file");
Files.writeString(cacheAsFile, "occupied"); // createDirectories under it must fail
LibraryLoader l = loader(Map.of(), cacheAsFile, bundled(LINUX_RESOURCE, payload));
Path got = l.resolve(); // the default supplier extracts under the system temp dir
assertFalse(got.startsWith(cacheAsFile));
assertArrayEquals(payload, Files.readAllBytes(got));
}

@Test
void cacheAndTempDirBothUnusableFails(@TempDir Path dir) throws IOException {
byte[] payload = "x".getBytes(StandardCharsets.UTF_8);
Path cacheAsFile = dir.resolve("cache-is-a-file");
Files.writeString(cacheAsFile, "occupied");
LibraryLoader l =
new LibraryLoader(Map.of(), cacheAsFile, "linux", "amd64", bundled(LINUX_RESOURCE, payload), () -> {
throw new IOException("temp denied");
});
HegelException e = assertThrows(HegelException.class, l::resolve);
assertTrue(e.getMessage().contains("Failed to unpack bundled libhegel"));
// The terminal error reports both causes: the temp failure as the cause, the cache failure
// in the message (and suppressed on the cause, so both stack traces survive).
assertTrue(e.getCause().getMessage().contains("temp denied"));
assertTrue(e.getMessage().contains("cache also unusable"));
assertTrue(e.getMessage().contains("cache-is-a-file"));
}
}
Loading