-
Notifications
You must be signed in to change notification settings - Fork 4
Guard macOS packaged natives: collision detection + smoke test #390
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,93 @@ | ||
| #!/usr/bin/env bash | ||
|
|
||
| # SPDX-FileCopyrightText: 2026 Bernard Ladenthin <bernard.ladenthin@gmail.com> | ||
| # | ||
| # SPDX-License-Identifier: MIT OR Apache-2.0 | ||
|
|
||
| # Merges the per-artifact native-library trees downloaded by the `*-libraries` glob into the | ||
| # single default-JAR resource tree — and FAILS LOUD if two artifacts claim the same file path. | ||
| # | ||
| # Why this exists: the `package` / `publish-snapshot` / `publish-release` jobs pull every build | ||
| # job whose artifact name ends in `-libraries` with one globbed `actions/download-artifact`. | ||
| # That is convenient (a new CPU platform ships in the default JAR by naming its artifact | ||
| # `<Something>-libraries`, no packaging change) but it is silently unsafe: an artifact name says | ||
| # nothing about which `{OS}/{ARCH}` subdirectory the job's CMake run actually wrote. When two | ||
| # artifacts carry the same relative path, `merge-multiple: true` extracts both onto that one | ||
| # path and the survivor can be a byte-level hybrid of the two, not either input. | ||
| # | ||
| # That is exactly what happened to macOS arm64: all three macOS build jobs write | ||
| # `Mac/aarch64/libjllama.dylib` (none of them passes -DOS_NAME/-DOS_ARCH, so CMakeLists | ||
| # auto-detects the same subdir) and all three used to upload under a `*-libraries` name. The | ||
| # published dylib became a hybrid whose ad-hoc linker signature no longer matched its own | ||
| # __TEXT pages, so macOS SIGKILLed every process that loaded it — shipped broken in 5.0.6 and | ||
| # several 5.0.7 snapshots. The immediate fix renamed those artifacts out of the glob; this | ||
| # script is the backstop that stops the same hole from being reopened by a future job. | ||
| # | ||
| # NOTE ON WHAT *CANNOT* WORK AS A GUARD: asserting "exactly one library per {OS}/{ARCH}" on the | ||
| # merged tree does not detect this. The collision overwrites one path, so the merged tree still | ||
| # holds exactly one file there — a corrupt one. The collision is only observable BEFORE the | ||
| # merge, which is why this script does the merge itself instead of checking afterwards. | ||
| # | ||
| # Usage: merge-native-artifacts.sh <staging-dir> <dest-dir> | ||
| # <staging-dir> output of `actions/download-artifact` with `pattern: "*-libraries"` and | ||
| # `merge-multiple: false`, i.e. one subdirectory per artifact name. | ||
| # <dest-dir> the tree the artifacts are merged into, e.g. | ||
| # llama/src/main/resources/net/ladenthin/llama/ | ||
| # | ||
| # Fail-loud: aborts when the staging directory holds no artifacts (a silently empty default JAR | ||
| # is worse than a red job) and when any relative path is claimed by more than one artifact. | ||
|
|
||
| set -euo pipefail | ||
|
|
||
| STAGING="${1:?usage: merge-native-artifacts.sh <staging-dir> <dest-dir>}" | ||
| DEST="${2:?usage: merge-native-artifacts.sh <staging-dir> <dest-dir>}" | ||
|
|
||
| if [ ! -d "$STAGING" ]; then | ||
| echo "::error::staging directory '$STAGING' does not exist — the globbed download did not run." >&2 | ||
| exit 1 | ||
| fi | ||
|
|
||
| # One subdirectory per downloaded artifact. Depth 1 only: everything below is artifact content. | ||
| artifacts=() | ||
| while IFS= read -r d; do artifacts+=("$(basename "$d")"); done < <(find "$STAGING" -mindepth 1 -maxdepth 1 -type d | sort) | ||
|
|
||
| if [ "${#artifacts[@]}" -eq 0 ]; then | ||
| echo "::error::no '*-libraries' artifacts found in '$STAGING' — the default JAR would ship without native libraries." >&2 | ||
| exit 1 | ||
| fi | ||
|
|
||
| echo "Merging ${#artifacts[@]} native-library artifact(s) into $DEST" | ||
| for a in "${artifacts[@]}"; do echo " - $a"; done | ||
|
|
||
| # relpath -> space-separated list of artifacts that carry it. Bash 3.2 (macOS) has no | ||
| # associative arrays, so this stays a sorted "<relpath>\t<artifact>" stream processed by awk. | ||
| collisions="$( | ||
| for a in "${artifacts[@]}"; do | ||
| (cd "$STAGING/$a" && find . -type f | sed 's|^\./||' | while IFS= read -r f; do printf '%s\t%s\n' "$f" "$a"; done) | ||
| done | sort | awk -F'\t' ' | ||
| { if ($1 == prev) { owners = owners " " $2; n++ } else { if (n > 1) print prev "\t" owners; prev = $1; owners = $2; n = 1 } } | ||
| END { if (n > 1) print prev "\t" owners } | ||
| ' | ||
| )" | ||
|
Comment on lines
+64
to
+71
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Excellent collision detection algorithm: Using awk to find duplicate paths across artifacts before merging is the right approach. A post-merge check ("exactly one file per path") would miss the byte-level hybrid corruption where two different dylibs get overwritten onto one path. The comment on lines 26-29 correctly explains why this pre-merge timing is essential. Well-designed defensive pattern. Minor note: The |
||
|
|
||
| if [ -n "$collisions" ]; then | ||
| echo "::error::two or more '*-libraries' artifacts write the same path — merging them would produce a hybrid, corrupt native library." >&2 | ||
| while IFS=$'\t' read -r path owners; do | ||
| echo "::error:: $path <- claimed by:$owners" >&2 | ||
| done <<< "$collisions" | ||
| cat >&2 <<'EOF' | ||
| ::error::Fix: only ONE artifact per {OS}/{ARCH} may be named `*-libraries`. Rename the extra | ||
| ::error::build jobs' artifacts outside the glob (as the macOS jobs do: macos-15-metal / | ||
| ::error::macos-14-metal / macos-15-no-metal) and download the variant that ships explicitly by | ||
| ::error::name. See CLAUDE.md, "macOS arm64: three build jobs, one shipped dylib". | ||
| EOF | ||
| exit 1 | ||
| fi | ||
|
|
||
| mkdir -p "$DEST" | ||
| for a in "${artifacts[@]}"; do | ||
| cp -R "$STAGING/$a/." "$DEST/" | ||
| done | ||
|
|
||
| echo "Merged native tree:" | ||
| find "$DEST" -type f | sort | sed 's|^| |' | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,77 @@ | ||
| #!/usr/bin/env bash | ||
|
|
||
| # SPDX-FileCopyrightText: 2026 Bernard Ladenthin <bernard.ladenthin@gmail.com> | ||
| # | ||
| # SPDX-License-Identifier: MIT OR Apache-2.0 | ||
|
|
||
| # macOS post-`package` smoke: verifies the libjllama.dylib that is actually INSIDE the packaged jar | ||
| # — its code signature, and that a JVM can load it and cross the JNI boundary. | ||
| # | ||
| # Why this exists (the gap it closes): the three macOS Java test jobs each run against the dylib | ||
| # THEIR OWN build job produced. Nothing in the pipeline ever loaded the one that goes into the | ||
| # published jar. So when the `*-libraries` artifact glob merged three different macOS dylibs onto | ||
| # one path and produced a byte-level hybrid, the result — a library whose ad-hoc linker signature no | ||
| # longer matched its own __TEXT pages, which macOS SIGKILLs on load — shipped in 5.0.6 and several | ||
| # 5.0.7 snapshots with an all-green pipeline. Linux and Windows already had the equivalent gate | ||
| # (`smoke-fatjar-linux` / `smoke-fatjar-windows`, downstream of `package`); macOS had none. | ||
| # | ||
| # This is the macOS member of the cross-repo "no release asset is attached that CI has not run" | ||
| # convention (workspace/policies/fat-jar-release-assets.md). It is NOT the shared | ||
| # smoke-fatjar-cli.sh that BitcoinAddressFinder and srcmorph run: this jar's Main-Class is a server | ||
| # that never exits, and the assertion that matters here is native-library loadability, not a CLI | ||
| # exit code. Same job shape, repo-specific assertions. | ||
| # | ||
| # Deliberately model-free: no GGUF, no cache restore, no network — it runs in ~1 min. A full | ||
| # model-backed macOS server smoke would be strictly more, but the failure class that actually | ||
| # shipped is caught here, so this is the version that is cheap enough to always run. | ||
| # | ||
| # Usage: smoke-native-macos.sh <jar-dir> <jar-glob> | ||
| # <jar-dir> directory to search for the jar (recursively) | ||
| # <jar-glob> filename glob; must match EXACTLY ONE jar | ||
|
|
||
| set -euo pipefail | ||
|
|
||
| JAR_DIR="${1:?usage: smoke-native-macos.sh <jar-dir> <jar-glob>}" | ||
| JAR_GLOB="${2:?usage: smoke-native-macos.sh <jar-dir> <jar-glob>}" | ||
|
|
||
| SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" | ||
|
|
||
| fail() { | ||
| echo "::error::$*" >&2 | ||
| exit 1 | ||
| } | ||
|
|
||
| [ -d "$JAR_DIR" ] || fail "jar directory '$JAR_DIR' does not exist" | ||
|
|
||
| jars=() | ||
| while IFS= read -r j; do jars+=("$j"); done < <(find "$JAR_DIR" -type f -name "$JAR_GLOB" | sort) | ||
| [ "${#jars[@]}" -eq 1 ] \ | ||
| || fail "expected exactly 1 jar matching '$JAR_GLOB' under '$JAR_DIR', got ${#jars[@]}: ${jars[*]:-none}" | ||
| JAR="$(cd "$(dirname "${jars[0]}")" && pwd)/$(basename "${jars[0]}")" | ||
| echo "smoke jar: $JAR" | ||
|
|
||
| DYLIB_ENTRY="net/ladenthin/llama/Mac/aarch64/libjllama.dylib" | ||
| WORK="$(mktemp -d)" | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Cleanup pattern: The |
||
| trap 'rm -rf "$WORK"' EXIT | ||
|
|
||
| unzip -o -q "$JAR" "$DYLIB_ENTRY" -d "$WORK" \ | ||
| || fail "the jar does not contain $DYLIB_ENTRY — the macOS natives never reached the package job" | ||
| DYLIB="$WORK/$DYLIB_ENTRY" | ||
|
Comment on lines
+57
to
+59
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Correct error handling for missing dylib: The This is the direct check for the actual failure mode that shipped: if the jar was packaged without the macOS natives, this catch saves from silent failure. |
||
| echo "extracted: $(cd "$(dirname "$DYLIB")" && pwd)/$(basename "$DYLIB") ($(wc -c < "$DYLIB") bytes)" | ||
|
|
||
| # 1) Signature vs. content. `--strict` re-hashes the code pages and compares them against the | ||
| # signature's stored hashes, so a dylib assembled from two different builds fails here with the | ||
| # exact page mismatch — the direct check for the shipped corruption. An ad-hoc signature (what | ||
| # the linker emits on arm64) is expected and fine; only a MISMATCH is a failure. | ||
| echo "== codesign --verify --strict ==" | ||
| codesign --verify --strict --verbose=2 "$DYLIB" \ | ||
| || fail "code signature does not match the dylib's own pages — the packaged library is corrupt (macOS would SIGKILL any process that loads it)" | ||
|
|
||
| # 2) The JVM must actually be able to map it and call through JNI. This is what a consumer does, | ||
| # and it is the only check that covers load-time failures the signature check cannot see | ||
| # (missing dependent library, wrong architecture, unresolved JNI_OnLoad class lookup). | ||
| echo "== JVM load + JNI round-trip ==" | ||
| java -cp "$JAR" "$SCRIPT_DIR/smoke/NativeLoadSmoke.java" \ | ||
| || fail "the packaged native library did not load in a JVM" | ||
|
|
||
| echo "smoke test PASSED" | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,45 @@ | ||
| // SPDX-FileCopyrightText: 2026 Bernard Ladenthin <bernard.ladenthin@gmail.com> | ||
| // | ||
| // SPDX-License-Identifier: MIT OR Apache-2.0 | ||
|
|
||
| import net.ladenthin.llama.LlamaModel; | ||
| import net.ladenthin.llama.value.LlamaCppVersion; | ||
|
|
||
| /** | ||
| * Model-free load smoke for the PACKAGED native library, run by {@code .github/smoke-native-macos.sh} | ||
| * via the JDK single-file source launcher ({@code java -cp <fatjar> NativeLoadSmoke.java}) — no | ||
| * Maven, no test framework, no GGUF. | ||
| * | ||
| * <p>Touching {@link LlamaModel} runs its static initializer, which is | ||
| * {@code LlamaLoader.initialize() -> System.load() -> JNI_OnLoad}: the library is extracted from the | ||
| * jar, mapped by the OS loader, and every class its {@code FindClass} calls reference is resolved. | ||
| * On macOS that mapping is also where a dylib whose ad-hoc signature no longer covers its own | ||
| * {@code __TEXT} pages is rejected — the failure that shipped in 5.0.6. Then | ||
| * {@link LlamaModel#getLlamaCppBuildInfo()} crosses the JNI boundary for real and is checked against | ||
| * the compile-time pin, so a jar carrying a stale or foreign library fails here rather than in a | ||
| * user's process.</p> | ||
| * | ||
| * <p>This is the packaged-artifact counterpart of {@code NativeLibraryLoadSmokeTest}, which asserts | ||
| * the same two things against {@code target/classes} during {@code mvn test} — that one cannot see | ||
| * what the assembled jar contains.</p> | ||
| */ | ||
| public final class NativeLoadSmoke { | ||
|
|
||
| private NativeLoadSmoke() {} | ||
|
|
||
| public static void main(String[] args) { | ||
| final String pinned = LlamaCppVersion.LLAMA_CPP_VERSION; | ||
|
|
||
| // Forces LlamaLoader.initialize() -> System.load() -> JNI_OnLoad. | ||
| final String build = LlamaModel.getLlamaCppBuildInfo(); | ||
|
|
||
| if (build == null || build.isEmpty()) { | ||
| throw new IllegalStateException("getLlamaCppBuildInfo() returned no build identifier"); | ||
| } | ||
| if (!build.startsWith(pinned)) { | ||
| throw new IllegalStateException("packaged native library reports llama.cpp build '" + build | ||
| + "' but this jar pins '" + pinned + "' — the jar carries the wrong native library"); | ||
| } | ||
|
Comment on lines
+36
to
+42
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Build version validation: Using The null/empty check on line 36-37 is good defensive coding to catch JNI failures early. |
||
| System.out.println("native load smoke OK: pinned=" + pinned + " linked=" + build); | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Bash 3.2 compatibility note: The comment mentions avoiding associative arrays for macOS compatibility. This is good defensive programming. The current approach (find + sort + awk) is portable and works on any bash version.
If this pattern is used elsewhere, consider documenting the Bash 3.2 requirement in a comment near the script header.