Guard macOS packaged natives: collision detection + smoke test - #390
Conversation
…ions PR #388 fixed the corrupt macOS dylib by renaming the three macOS arm64 artifacts out of the `*-libraries` glob, so only `macos-15-metal` ships and it is downloaded by name. That fixes the instance; the glob itself is unchanged, so a future job that ends its artifact name in `-libraries` and writes an already-claimed {OS}/{ARCH} reopens the same hole — silently, because an artifact name says nothing about the path the job wrote. The `package`, `publish-snapshot` and `publish-release` jobs now download the glob UNMERGED (one subdirectory per artifact) and merge it with the new .github/merge-native-artifacts.sh, which fails the job when any relative path is claimed by more than one artifact, and when the glob matched nothing at all (a silently native-library-free default JAR). The check has to run before the merge: a collision leaves exactly one file on the path, so a post-merge assertion such as "exactly one dylib per {OS}/{ARCH}" cannot observe it — the merged tree looks correct and holds a hybrid binary. Verified against a staging tree reproducing the pre-#388 state (three macOS artifacts on Mac/aarch64/libjllama.dylib): fails with the colliding path and its claimants listed; the post-#388 state passes. Docs: CLAUDE.md gains "macOS arm64: three build jobs, one shipped dylib" (the three jobs, which one ships and why, the at-most-one-artifact-per-{OS}/{ARCH} invariant, the guard). TODO.md records the deeper gap this exposed: the macOS Java test jobs each test their own build job's dylib, so nothing ever loads the packaged one — Linux and Windows have smoke-fatjar-* downstream of `package`, macOS has no equivalent, which is why a SIGKILL-on-load binary shipped through three releases with a green pipeline. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Tzo7Yi8SXP6WxhqZXXbsco
macOS was the one platform with no post-`package` gate: the three macOS Java test jobs each run against the dylib their own build job produced, so nothing ever loaded the copy that goes into the published jar. That is why a hybrid libjllama.dylib — whose ad-hoc signature no longer covered its own __TEXT pages, so macOS SIGKILLs any process that loads it — shipped in 5.0.6 and several 5.0.7 snapshots with an all-green pipeline. Linux and Windows already had smoke-fatjar-linux / -windows downstream of `package`. The new job downloads llama-jars and runs .github/smoke-native-macos.sh: extract Mac/aarch64/libjllama.dylib from the default fat jar, `codesign --verify --strict` it (this re-hashes the code pages against the signature's stored hashes, so a dylib assembled from two builds fails exactly here), then load it in a real JVM via the JDK single-file source launcher (.github/smoke/NativeLoadSmoke.java) and cross JNI for getLlamaCppBuildInfo(), checked against the LlamaCppVersion pin. It targets the default fat jar because there is no all-macos-* fat jar to target — macOS has no GPU classifier, Metal ships in the default jar, so package-fatjars builds no macOS variant. It asserts native loadability rather than a CLI exit code, so it cannot reuse smoke-test-fatjar.sh, whose backend-manifest grep never matches the manifest-less default jar. Model-free by design (~1 min, no GGUF, no cache restore, no network): a slow smoke gets made non-gating and then the gap reopens. This is the macOS member of the cross-repo "no release asset is attached that CI has not run" convention; BitcoinAddressFinder and srcmorph implement it with a shared smoke-fatjar-cli.sh. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Tzo7Yi8SXP6WxhqZXXbsco
| 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 } | ||
| ' | ||
| )" |
There was a problem hiding this comment.
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 awk script relies on the input being sorted; line 52 ensures this with sort, so the algorithm is sound.
|
|
||
| # 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) |
There was a problem hiding this comment.
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.
| 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" |
There was a problem hiding this comment.
Correct error handling for missing dylib: The unzip command will fail with a non-zero exit code if $DYLIB_ENTRY does not exist in the jar, and the || fail pattern catches it.
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 "smoke jar: $JAR" | ||
|
|
||
| DYLIB_ENTRY="net/ladenthin/llama/Mac/aarch64/libjllama.dylib" | ||
| WORK="$(mktemp -d)" |
There was a problem hiding this comment.
Cleanup pattern: The trap 'rm -rf "$WORK"' EXIT is the correct idiom for ensuring temporary files are cleaned up even if the script exits early or errors. Good defensive programming.
|
| 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"); | ||
| } |
There was a problem hiding this comment.
Build version validation: Using startsWith() allows for git commit hash variations after the b-number (e.g., "b10423-abc123def" vs "b10423" in the pin). This is intentional and correct — it validates the major version was linked while allowing minor commit variations.
The null/empty check on line 36-37 is good defensive coding to catch JNI failures early.
|
Overall Review: Excellent PR This PR implements a comprehensive, well-designed solution to the silent corruption bug that shipped in 5.0.6. The three-layer defensive approach is sound with prevention at source, detection at merge time with collision checking, and end-to-end validation with model-free smoke test. Key Accomplishments:
Code Quality: Defensive bash practices throughout, smart collision detection using awk, clean Java smoke test, excellent documentation. Security: No security concerns identified. All scripts use safe patterns with proper error handling and fail-loud behavior. This successfully closes the gap identified in 5.0.6 where corrupt dylib shipped with a green pipeline. Detailed inline comments posted on key code sections. |



Summary
Added
.github/merge-native-artifacts.sh: A collision-detection script that merges per-artifact native-library trees and fails loudly if any{OS}/{ARCH}path is claimed by more than one*-librariesartifact. This guards against the silent corruption that shipped in 5.0.6 when the artifact glob merged three different macOS dylibs onto one path, producing a byte-level hybrid whose ad-hoc signature no longer matched its own__TEXTpages (macOS SIGKILLs on load).Added
.github/smoke-native-macos.sh+.github/smoke/NativeLoadSmoke.java: A model-free macOS post-package smoke test that verifies the dylib inside the packaged jar viacodesign --verify --strict(re-hashes code pages against stored hashes) and a real JVM load + JNI round-trip. Closes the gap that let the corrupt dylib ship with a green pipeline: the three macOS Java test jobs each tested their own build's dylib, so nothing exercised the packaged artifact until now.Updated
publish.yml: Three consumer jobs (package,publish-snapshot,publish-release) now download the*-librariesglob unmerged and invokemerge-native-artifacts.shto do the merge with collision detection. Addedsmoke-fatjar-macosas a gating dependency for both publish jobs.Updated
CLAUDE.md+TODO.md: Documented the macOS arm64 three-job / one-shipped-dylib invariant, the guard mechanism, and the end-to-end gate.Context
macOS arm64 is the only
{OS}/{ARCH}built by more than one job, and it has no classifier — all three variants ship into the same default-JAR pathMac/aarch64/libjllama.dylib. When the*-librariesartifact glob merged three different dylibs onto one path withmerge-multiple: true, the result was a corrupt hybrid. The shipped dylib's ad-hoc linker signature no longer matched its own__TEXTpages, causing macOS to SIGKILL every process that loaded it (5.0.6 and several 5.0.7 snapshots, 66/4078 and 1141/4097 code pages failed their stored hashes).The fix has three layers:
macos-15-metal) is now chosen by explicit download by name; the other two are named outside the glob.merge-native-artifacts.shdetects collisions before they corrupt the tree and fails the job.smoke-fatjar-macosloads the packaged dylib in a real JVM and verifies its code signature, catching any future reopening of the hole.Test plan
merge-native-artifacts.shtested locally with collision scenarios (two artifacts claiming the same path → job fails; no artifacts → job fails; single artifact → merge succeeds)smoke-native-macos.sh+NativeLoadSmoke.javatested locally on macOS 15 (extracts dylib from jar, verifies signature, loads in JVM, crosses JNI boundary)Related issues / PRs
Fixes the corruption that shipped in 5.0.6 and several 5.0.7 snapshots. Implements the macOS member of the cross-repo "no release asset is attached that CI has not run" convention (see
../workspace/policies/fat-jar-release-assets.md).Checklist
CONTRIBUTING.mdandCODE_OF_CONDUCT.mdhttps://claude.ai/code/session_01Tzo7Yi8SXP6WxhqZXXbsco