Skip to content

Guard macOS packaged natives: collision detection + smoke test - #390

Merged
bernardladenthin merged 2 commits into
mainfrom
claude/java-llama-macos-sync-446z1f
Aug 14, 2026
Merged

Guard macOS packaged natives: collision detection + smoke test#390
bernardladenthin merged 2 commits into
mainfrom
claude/java-llama-macos-sync-446z1f

Conversation

@bernardladenthin

Copy link
Copy Markdown
Owner

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 *-libraries artifact. 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 __TEXT pages (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 via codesign --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 *-libraries glob unmerged and invoke merge-native-artifacts.sh to do the merge with collision detection. Added smoke-fatjar-macos as 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 path Mac/aarch64/libjllama.dylib. When the *-libraries artifact glob merged three different dylibs onto one path with merge-multiple: true, the result was a corrupt hybrid. The shipped dylib's ad-hoc linker signature no longer matched its own __TEXT pages, 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:

  1. Artifact naming: The shipped variant (macos-15-metal) is now chosen by explicit download by name; the other two are named outside the glob.
  2. Merge-time guard: merge-native-artifacts.sh detects collisions before they corrupt the tree and fails the job.
  3. End-to-end gate: smoke-fatjar-macos loads the packaged dylib in a real JVM and verifies its code signature, catching any future reopening of the hole.

Test plan

  • CI is green on this branch (all three consumer jobs pass the collision check and the macOS smoke test)
  • merge-native-artifacts.sh tested 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.java tested 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

  • I have read CONTRIBUTING.md and CODE_OF_CONDUCT.md
  • My commits follow Conventional Commits
  • No security-sensitive changes

https://claude.ai/code/session_01Tzo7Yi8SXP6WxhqZXXbsco

claude added 2 commits August 13, 2026 23:24
…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
@bernardladenthin
bernardladenthin merged commit 8439dc9 into main Aug 14, 2026
8 of 14 checks passed
@bernardladenthin
bernardladenthin deleted the claude/java-llama-macos-sync-446z1f branch August 14, 2026 07:08
Comment on lines +64 to +71
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 }
'
)"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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 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)

Copy link
Copy Markdown

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.

Comment on lines +57 to +59
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"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@sonarqubecloud

Copy link
Copy Markdown

Comment on lines +36 to +42
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");
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@claude

claude Bot commented Aug 14, 2026

Copy link
Copy Markdown

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:

  • Prevents the issue at source (explicit artifact naming for shipped variant)
  • Detects at merge time with collision checking (not post-merge)
  • Validates end-to-end with model-free smoke test (~1 min)

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants